Chapter 03 스프링 부트에서 JPA로 데이터베이스 다뤄보자 2

LeeKyoungChang·2022년 5월 17일
0
post-thumbnail

스프링 부트와 AWS로 혼자 구현하는 웹 서비스 를 공부하고 정리한 내용입니다.

 

📖 C. 등록/수정/조회 API 만들기

🔔 API를 만들기 위해 총 3개의 클래스가 필요하다!

  • Request 데이터를 받을 Dto
  • API 요청을 받은 Controller
  • 트랜잭션, 도메인 기능 간의 순서를 보장하는 Service

 

💡 참고

  • Service에서 비즈니스 로직을 처리한다는 것은 잘못된 것이다.
  • Service는 트랜잭션, 도메인 간 순서 보장의 역할만 한다.

 

✔ 비즈니스 로직은 누가 처리하냐?

사진3

Web Layer

  • 흔히 사용하는 컨트롤러(@Controller)와 JSP/Freemarker 등의 뷰 템플릿 영역
  • 이외에도 필터(@Filter), 인터셉터, 컨트롤러 어드바이스(@ControllerAdvice) 등 외부 요청과 응답에 대한 전반적인 영역을 이야기한다.

Service Layer

  • @Service에 사용되는 서비스 영역
  • 일반적으로 ControllerDao의 중간 영역에서 사용된다.
  • @Transactional이 사용되어야 하는 영역이기도 하다.

Repository Layer

  • Database와 같이 데이터 저장소에 접근하는 영역이다.
  • 기존에 개발하셨던 사람들이면 Dao(Data Access Object) 영역으로 이해하면 쉬울 것이다.

Dtos

  • Dto(Data Transfer Object)는 계층 간에 데이터 교환을 위한 객체를 이야기하며 Dtos는 이들의 영역을 말한다.
  • ex) 뷰 템플릿 엔진에서 사용될 객체나 Repositoy 계층에서 결과로 넘겨준 객체 등이 Dto이다.

Domian Model

  • 도메인이라 불리는 개발 대상을 모든 사람이 동일한 관점에서 이해할 수 있고 공유할 수 있도록 단순화 시킨 것을 도메인 모델이라고 한다.
  • ex) 택시 앱에서 배차, 탑승, 요금 등이 모두 도메인이 될 수 있다.
  • 비즈니스 로직을 처리한다.
  • @Entity가 사용된 영역 역시 도메인 모델이다.
  • 다만, 무조건 데이터베이스의 테이블과 관계가 있어야 하는 것은 아니다. (VO처럼 값 객체들도 도메인 모델에 해당하기 때문이다.)

 

✔ 트랜잭션 스크립트

기존에 서비스로 처리하던 방식

ex) 주문 취소 로직 작성

슈도 코드

@Transactional
public Order cancelOrder(int orderId){
	1) 데이터베이스로부터 주문정보(Orders), 결제정보(Billing), 배송정보(Delivery) 조회
	2) 배송 취소를 해야 하는지 확인
	3) if (배송 중이라면){
		배송 취소로 변경
	}
	4) 각 테이블에 취소 상태 Update
}

 

실제 코드

@Transactional
public Order cancelOrder(nt orderId){
	// 1)
	OrdersDto order = ordersDao.selectOrders(orderId);
	BillingDto billing = billingDao.selectBilling(orderId);
	DeliveryDto delivery = deliveryDao.selectDelivery(orderId);
	
	// 2)
	String deliveryStatus = delivery.getStatus();
	
	// 3)
	if("IN_PROGRESS".equals(deliveryStatus)){
		delivery.setStatus("CANCEL");
		deliveryDao.update(delivery);
	}
	
	// 4)
	order.setStatus("CANCEL");
	ordersDao.update(order);
	
	billing.setStatus("CANCEL");
	deliveryDao.update(billing);
	
	return order;
}
  • 모든 로직이 서비스 클래스 내부에서 처리된다.
  • 그러다 보니, 서비스 계층이 무의미하며, 객체란 단순히 데이터 덩어리 역할만 하게 된다.

 

✔ 도메인 모델에서 처리할 경우

@Transactional
public Order cancelOrder(int orderId){
	
	// 1)
	Order order = orderRepository.findById(orderId);
	Billing billing = billingRepository.findByOrderId(orderId);
	Delivery delivery = deliveryRepository.findByOrderId(orderId);
	
	// 2-3)
	delivery.cancel();
	
	// 4)
	order.cancel();
	billing.cancel();
	
	return order;

}
  • order, billing, delivery가 각자 본인의 취소 이벤트 처리를 한다.
  • 서비스 메소드는 트랜잭션과 도메인 간의 순서만 보장해준다.

 

✔ 등록, 수정, 삭제 기능 소스

(1) 등록 기능

PostApiController

package springbootawsbook.springawsbook.web;  
  
import lombok.RequiredArgsConstructor;  
import org.springframework.web.bind.annotation.PostMapping;  
import org.springframework.web.bind.annotation.RequestBody;  
import org.springframework.web.bind.annotation.RestController;  
import springbootawsbook.springawsbook.service.PostsService;  
import springbootawsbook.springawsbook.web.dto.PostsSaveRequestDto;  
  
@RequiredArgsConstructor  
@RestController  
public class PostApiController {  
  
    private final PostsService postsService;  
  
    @PostMapping("/api/v1/posts")  
    public Long save(@RequestBody PostsSaveRequestDto requestDto){  
        return postsService.save(requestDto);  
    }  
}

 

PostsService

package springbootawsbook.springawsbook.service;  
  
import lombok.RequiredArgsConstructor;  
import org.springframework.stereotype.Service;  
import org.springframework.transaction.annotation.Transactional;  
import springbootawsbook.springawsbook.domain.posts.PostsRepository;  
import springbootawsbook.springawsbook.web.dto.PostsSaveRequestDto;  
  
@RequiredArgsConstructor  
@Service  
public class PostsService {  
  
    private  final PostsRepository postsRepository;  
  
    @Transactional  
    public Long save(PostsSaveRequestDto requestDto) {  
        return postsRepository.save(requestDto.toEntity())  
                .getId();  
    }  
}

🎁 스프링에서 Bean을 주입받는 방식
@Autowired, setter, 생성자

  • 이 중에서 가장 권장하는 방식은 생성자로 주입받는 방식
  • @Autowired는 권장하지 않는다.
  • 생성자로 Bean 객체를 받도록 하면 @Autowired와 동일한 효과를 볼 수 있다.

@RequiredArgsConstructor : final이 선언된 모든 필드를 인자값으로 하는 생성자를 대신 생성해준다.

  • 생성자를 직접 안쓰고 롬복 어노테이션을 사용하는 이유 : 해당 클래스의 의존성 관계가 변경될 때마다 생성자 코드를 계속해서 수정하는 번거로움을 해결하기 위해서 사용한다.
  • 롬복 어노테이션이 있으면 컨트롤러에 새로운 서비스를 추가하거나, 제거하거나 등 어떤 상황이 발생해도 생성자 코드는 전혀 손대지 않아도 된다.

 

PostsSaveRequestDto

ControllerService에서 사용할 Dto 클래스 생성

package springbootawsbook.springawsbook.web.dto;  
  
import lombok.Builder;  
import lombok.Getter;  
import lombok.NoArgsConstructor;  
import springbootawsbook.springawsbook.domain.posts.Posts;  
  
@Getter  
@NoArgsConstructor  
public class PostsSaveRequestDto {  
  
    private String title;  
    private String content;  
    private String author;  
  
    @Builder  
    public PostsSaveRequestDto(String title, String content, String author) {  
        this.title = title;  
        this.content = content;  
        this.author = author;  
    }  
  
    public Posts toEntity() {  
        return Posts.builder()  
                .title(title)  
                .content(content)  
                .author(author)  
                .build();  
    }  
}
  • Entity 클래스와 거의 유사한 형태임에도 Dto 클래스를 추가로 생성
  • 다만, 절대로 Entity 클래스Request/Response 클래스로 사용해서는 안된다.

 

🎁 Entity 클래스

  • Entity 클래스는 데이터베이스와 맞닿은 핵심 클래스
  • Entity 클래스를 기준으로 테이블이 생성되고, 스키마가 변경된다.
  • 수많은 서비스 클래스나 비즈니스 로직들이 Entity 클래스를 기준으로 동작한다.
  • Entity 클래스가 변경되면 여러 클래스에 영향을 끼치지만, Request와 Response 용 Dto는 View를 위한 클래스라 정말 자주 변경이 필요하다.

 

  • Controller에서 결괏값으로 여러 테이블을 조인해서 줘야 할 경우, 빈번하므로 Entity 클래스만으로 표현하기가 어려운 경우가 많다.
  • Entity 클래스와 Controller에서 쓸 Dto는 분리해서 사용해야 한다!

 

PostsApiControllerTest - 테스트 코드

package springbootawsbook.springawsbook.web;  
  
  
import org.junit.jupiter.api.AfterEach;  
import org.junit.jupiter.api.Test;  
import org.springframework.beans.factory.annotation.Autowired;  
import org.springframework.boot.test.context.SpringBootTest;  
import org.springframework.boot.test.web.client.TestRestTemplate;  
import org.springframework.boot.web.server.LocalServerPort;  
import org.springframework.http.HttpStatus;  
import org.springframework.http.ResponseEntity;  
import springbootawsbook.springawsbook.domain.posts.Posts;  
import springbootawsbook.springawsbook.domain.posts.PostsRepository;  
import springbootawsbook.springawsbook.web.dto.PostsSaveRequestDto;  
  
import java.util.List;  
  
import static org.assertj.core.api.Assertions.*;  
import static org.junit.jupiter.api.Assertions.*;  
  
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)  
class PostApiControllerTest {  
  
    @LocalServerPort  
    private int port;  
  
    @Autowired  
    private TestRestTemplate restTemplate;  
  
    @Autowired  
    private PostsRepository postsRepository;  
  
    @AfterEach  
    public void tearDown() throws Exception{  
        postsRepository.deleteAll();  
    }  
  
    @Test  
    public void Posts_등록된다 () throws Exception{  
        // given  
        String title = "title";  
        String content = "content";  
        PostsSaveRequestDto requestDto = PostsSaveRequestDto  
                .builder()  
                .title(title)  
                .content(content)  
                .author("author")  
                .build();  
  
        String url = "http://localhost:"+port + "/api/v1/posts";  
  
        // when  
        ResponseEntity<Long> responseEntity = restTemplate.postForEntity(url, requestDto, Long.class);  
  
        // then  
        assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK);  
        assertThat(responseEntity.getBody()).isGreaterThan(0L);  
  
        List<Posts> all = postsRepository.findAll();  
        assertThat(all.get(0).getTitle()).isEqualTo(title);  
        assertThat(all.get(0).getContent()).isEqualTo(content);  
    }  
      
}
  • @WebMvcTest를 사용하지 않은 이유 : @WebMvcTest의 경우 JPA 기능이 작동하지 않는다.
    • ControllerControllerAdvice 등 외부 연동과 관련된 부분만 활성화된다.
  • JPA 기능까지 한 번에 테스트할 때는 @SpringBootTestTestRestTemplate을 사용하면 된다.

 

실행 결과

사진5

사진6

  • WebEnvironment.RANDOM_PORT로 인한 랜덤 포트 실행
  • insert 쿼리가 실행

 

(2) 수정, 조회

PostsApiController

package springbootawsbook.springawsbook.web;  
  
import lombok.RequiredArgsConstructor;  
import org.springframework.web.bind.annotation.*;  
import springbootawsbook.springawsbook.service.PostsService;  
import springbootawsbook.springawsbook.web.dto.PostsResponseDto;  
import springbootawsbook.springawsbook.web.dto.PostsSaveRequestDto;  
import springbootawsbook.springawsbook.web.dto.PostsUpdateRequestDto;  
  
@RequiredArgsConstructor  
@RestController  
public class PostApiController {  
  
    private final PostsService postsService;  
  
    @PostMapping("/api/v1/posts")  
    public Long save(@RequestBody PostsSaveRequestDto requestDto){  
        return postsService.save(requestDto);  
    }  
  
    @PutMapping("/api/v1/posts/{id}")  
    public Long update(@PathVariable Long id, @RequestBody PostsUpdateRequestDto requestDto){  
        return postsService.update(id, requestDto);  
    }  
  
    @GetMapping("/api/v1/posts/{id}")  
    public PostsResponseDto findById(@PathVariable Long id) {  
        return postsService.findById(id);  
    }  
}

 

PostsResponseDto

package springbootawsbook.springawsbook.web.dto;  
  
import lombok.Getter;  
import springbootawsbook.springawsbook.domain.posts.Posts;  
  
@Getter  
public class PostsResponseDto {  
  
    private Long id;  
    private String title;  
    private String content;  
    private String author;  
  
    public PostsResponseDto(Posts entity) {  
        this.id = entity.getId();  
        this.title = entity.getTitle();  
        this.content = entity.getContent();  
        this.author = entity.getAuthor();  
    }  
}
  • PostsResponseDto는 Entity의 필드 중 일부만 사용한다.
  • 생성자로 Entity를 받아 필드에 값을 넣는다.
  • 굳이 모든 필드를 가진 생성자가 필요하진 않으므로 Dto는 Entity를 받아 처리한다.

 

PostsUpdateRequestDto

package springbootawsbook.springawsbook.web.dto;  
  
import lombok.Builder;  
import lombok.Getter;  
import lombok.NoArgsConstructor;  
  
@Getter  
@NoArgsConstructor  
public class PostsUpdateRequestDto {  
    private String title;  
    private String content;  
  
    @Builder  
    public PostsUpdateRequestDto(String title, String content){  
        this.title = title;  
        this.content = content;  
    }  
}

 

Posts

package springbootawsbook.springawsbook.domain.posts;  
  
import lombok.Builder;  
import lombok.Getter;  
import lombok.NoArgsConstructor;  
  
import javax.persistence.*;  
  
@Getter  
@NoArgsConstructor  
@Entity  
public class Posts {  
    @Id  
    @GeneratedValue(strategy = GenerationType.IDENTITY)  
    private Long id;  
  
    @Column(length = 500, nullable = false)  
    private String title;  
  
    @Column(columnDefinition = "TEXT", nullable = false)  
    private String content;  
  
    private String author;  
  
    @Builder  
    public Posts(String title, String content, String author) {  
        this.title = title;  
        this.content = content;  
        this.author = author;  
    }  
  
	// 추가
    public void update(String title, String content) {  
        this.title = title;  
        this.content = content;  
    }  
}

 

PostsService

package springbootawsbook.springawsbook.service;  
  
import lombok.RequiredArgsConstructor;  
import org.springframework.stereotype.Service;  
import org.springframework.transaction.annotation.Transactional;  
import springbootawsbook.springawsbook.domain.posts.Posts;  
import springbootawsbook.springawsbook.domain.posts.PostsRepository;  
import springbootawsbook.springawsbook.web.dto.PostsResponseDto;  
import springbootawsbook.springawsbook.web.dto.PostsSaveRequestDto;  
import springbootawsbook.springawsbook.web.dto.PostsUpdateRequestDto;  
  
@RequiredArgsConstructor  
@Service  
public class PostsService {  
  
    private  final PostsRepository postsRepository;  
  
    @Transactional  
    public Long save(PostsSaveRequestDto requestDto) {  
        return postsRepository.save(requestDto.toEntity())  
                .getId();  
    }  
  
	// 추가
    @Transactional  
    public Long update(Long id, PostsUpdateRequestDto requestDto) {  
        Posts posts = postsRepository.findById(id)  
                .orElseThrow(() -> new IllegalArgumentException("해당 게시글이 없습니다. id="+id));  
  
        posts.update(requestDto.getTitle(), requestDto.getContent());  
  
        return id;  
    }  
  
    public PostsResponseDto findById(Long id) {  
        Posts entity = postsRepository.findById(id)  
                .orElseThrow(() -> new IllegalArgumentException("해당 게시글이 없습니다. id=" + id));  
  
        return new PostsResponseDto(entity);  
    }  
}
  • 현재 update 기능에서 데이터베이스에 쿼리를 날리는 부분이 없다.
    • 이유 : JPA의 영속성 컨텍스트 때문이다.
    • 영속성 컨텍스트 : 엔티티를 영구 저장하는 환경
    • JPA의 핵심은 엔티티가 영속성 컨텍스트에 포함되어 있냐 아니냐로 갈린다.

 

📝 영속성 컨텍스트

  • JPA의 엔티티 매니저가 활성화된 상태로 트랙잰셕 안에서 데이터베이스에서 데이터를 가져오면 이 데이터는 영속성 컨텍스트가 유지된 상태이다.
  • 이 상태에서 해당 데이터의 값을 변경하면 트랜잭션이 끝나는 시점에 해당 테이블에 변경분을 반영한다. (더티 체킹 : Entity 객체의 값만 변경하면 별도로 Update 쿼리를 날릴 필요가 없다.)

 

PostsApiControllerTest - 테스트 코드

package springbootawsbook.springawsbook.web;  
  
  
import org.junit.jupiter.api.AfterEach;  
import org.junit.jupiter.api.Test;  
import org.springframework.beans.factory.annotation.Autowired;  
import org.springframework.boot.test.context.SpringBootTest;  
import org.springframework.boot.test.web.client.TestRestTemplate;  
import org.springframework.boot.web.server.LocalServerPort;  
import org.springframework.http.HttpEntity;  
import org.springframework.http.HttpMethod;  
import org.springframework.http.HttpStatus;  
import org.springframework.http.ResponseEntity;  
import springbootawsbook.springawsbook.domain.posts.Posts;  
import springbootawsbook.springawsbook.domain.posts.PostsRepository;  
import springbootawsbook.springawsbook.web.dto.PostsSaveRequestDto;  
import springbootawsbook.springawsbook.web.dto.PostsUpdateRequestDto;  
  
import java.util.List;  
  
import static org.assertj.core.api.Assertions.*;  
import static org.junit.jupiter.api.Assertions.*;  
  
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)  
class PostApiControllerTest {  
  
    @LocalServerPort  
    private int port;  
  
    @Autowired  
    private TestRestTemplate restTemplate;  
  
    @Autowired  
    private PostsRepository postsRepository;  
  
    @AfterEach  
    public void tearDown() throws Exception{  
        postsRepository.deleteAll();  
    }  
  
    @Test  
    public void Posts_등록된다 () throws Exception{  
        // given  
        String title = "title";  
        String content = "content";  
        PostsSaveRequestDto requestDto = PostsSaveRequestDto  
                .builder()  
                .title(title)  
                .content(content)  
                .author("author")  
                .build();  
  
        String url = "http://localhost:"+port + "/api/v1/posts";  
  
        // when  
        ResponseEntity<Long> responseEntity = restTemplate.postForEntity(url, requestDto, Long.class);  
  
        // then  
        assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK);  
        assertThat(responseEntity.getBody()).isGreaterThan(0L);  
  
        List<Posts> all = postsRepository.findAll();  
        assertThat(all.get(0).getTitle()).isEqualTo(title);  
        assertThat(all.get(0).getContent()).isEqualTo(content);  
    }  
  
  
	// 추가
    @Test  
    public void Posts_수정된다 () throws Exception{  
        // given  
        Posts savedPosts = postsRepository.save(Posts.builder()  
                .title("title")  
                .content("content")  
                .author("author")  
                .build());  
  
        Long updateId = savedPosts.getId();  
        String expectedTitle = "title2";  
        String expectedContent = "content2";  
  
        PostsUpdateRequestDto requestDto = PostsUpdateRequestDto.builder()  
                .title(expectedTitle)  
                .content(expectedContent)  
                .build();  
  
        String url = "http://localhost:"+port+"/api/v1/posts/"+updateId;  
  
        HttpEntity<PostsUpdateRequestDto> requestEntity = new HttpEntity<>(requestDto);  
  
        // when  
        ResponseEntity<Long> responseEntity = restTemplate.exchange(url,  
                HttpMethod.PUT,  
                requestEntity,  
                Long.class  
        );  
  
        // then  
        assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK);  
        assertThat(responseEntity.getBody()).isGreaterThan(0L);  
  
        List<Posts> all = postsRepository.findAll();  
        assertThat(all.get(0).getTitle()).  
                isEqualTo(expectedTitle);  
        assertThat(all.get(0).getContent()).  
                isEqualTo(expectedContent);  
    }  
      
}

 

실행 결과

사진7

사진8

사진9

insert into posts (author, content, title) values ('author', 'content', 'title');

 

최종 실행 결과
10

11

 

기본적인 등록/수정/조회 기능을 모두 만들고 테스트해보았다.
등록/수정은 테스트 코드로 보호해 주고 있으니 이후 변경 사항이 있어도 안전하게 변경할 수 있다.

 

📖 D. JPA Auditing으로 생성시간/수정시간 자동화하기

보통 엔티티에는 해당 데이터의 생성시간과 수정시간을 포함한다.
언제 만들어졌는지, 언제 수정되었는지 등은 차후 유지보수에 있어 굉장히 중요한 정보이다.
그러다보니, 매번 DB에 삽입하기 전, 갱신하기 전에 날짜 데이터를 등록/수정하는 코드가 여기저기 들어가게 된다.

 

✔ LocalData 사용

Java8부터 LocalDateLocalDateTime이 등장했다.

 

BaseTimeEntity

package springbootawsbook.springawsbook.domain;  
  
import lombok.Getter;  
import org.springframework.data.annotation.CreatedDate;  
import org.springframework.data.annotation.LastModifiedDate;  
import org.springframework.data.jpa.domain.support.AuditingEntityListener;  
  
import javax.persistence.EntityListeners;  
import javax.persistence.MappedSuperclass;  
import java.time.LocalDateTime;  
  
@Getter  
@MappedSuperclass  
@EntityListeners(AuditingEntityListener.class)  
public class BaseTimeEntity {  
  
    @CreatedDate  
    private LocalDateTime createdDate;  
  
    @LastModifiedDate  
    private LocalDateTime modifiedDate;  
}
  • BaseTimeEntity클래스 : 모든 Entity의 상위 클래스가 되어 Entity들의 createdDate, modifiedDate를 자동으로 관리하는 역할

  • @MappedSuperclass

    • JPA Entity 클래스들이 BaseTimeEntity을 상속할 경우 필드들(createdDate, modifiedDate)도 칼럼으로 인식하도록 한다.
  • @EntityListeners(AuditingEntityListener.class)

    • BaseTimeEntity 클래스에 Auditing 기능을 포함시킨다.
  • @CreatedDate

    • Entity가 생성되어 저장될 때 시간이 자동 저장된다.
  • @LastModifiedDate

    • 조회한 Entity의 값을 변경할 때 시간이 자동 저장된다.

 

Posts

public class Posts extends BaseTimeEntity{
	~
}
  • Posts 클래스가 BaseTimeEntity를 상속받도록 변경한다.

 

Application

package springbootawsbook.springawsbook;  
  
import org.springframework.boot.SpringApplication;  
import org.springframework.boot.autoconfigure.SpringBootApplication;  
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;  
  
@EnableJpaAuditing // JPA Auditing 활성화  
@SpringBootApplication  
public class SpringawsbookApplication {  
  
   public static void main(String[] args) {  
      SpringApplication.run(SpringawsbookApplication.class, args);  
   }  
  
}
  • JPA Auditing 어노테이션들을 모두 활성할 수 있도록 Application 클래스에 활성화 어노테이션 하나를 추가한다.

 

PostsRepositoryTest - JPA Auditing 테스트 코드

@Test  
public void BaseTimeEntity_등록 () throws Exception{  
    // given  
    LocalDateTime now = LocalDateTime.of(2022,5,17,0,0,0);  
    postsRepository.save(Posts.builder()  
            .title("title")  
            .content("content")  
            .author("author")  
            .build());  
    // when  
    List<Posts> postsList = postsRepository.findAll();  
	
    // then  
    Posts posts = postsList.get(0);  
  
    System.out.println(">>>>>> createDate=" + posts.getCreatedDate() + ", modifiedDate=" + posts.getModifiedDate());  
  
    assertThat(posts.getCreatedDate()).isAfter(now);  
    assertThat(posts.getModifiedDate()).isAfter(now);  
}

 

실행 결과
12

13

  • 테스트 코드 실행 시, 실제 시간이 잘 저장되어 있다.

 

앞으로 추가될 엔티티들은 더이상 등록일/수정일로 고민할 필요가 없다.
BaseTimeEntity만 상속받으면 자동으로 해결된다.

 

profile
"야, (오류 만났어?) 너두 (해결) 할 수 있어"

0개의 댓글