스프링 데이터 JPA

이상훈·2022년 10월 26일
0

Jpa

목록 보기
11/16

김영한님의 인프런 강의 '실전! 스프링 데이터 JPA'을 참고했습니다.

스프링 데이터 JPA

스프링 데이터 JPA는 스프링 프레임워크에서 JPA를 편리하게 사용할 수 있도록 지원하는 프로젝트이다. 개발자는 데이터 접근 계층을 개발할 때 구현 클래스 없이 인터페이스만 작성해도 개발을 완료할 수 있다.

설정

@Configuration
@EnableJpaRepositories(basePackages = "study.datajpa.repository")
public class AppConfig {}

원래는 위 코드처럼 위치를 지정해 줘야 하지만 스프링 부트 사용 시 생략 가능하다. @SpringBootApplication 가 해당 패키지와 하위 패키지를 위치로 자동 인식한다.


적용

순수 JPA로 구현한 MemberJpaRepository 대신에 스프링 데이터 JPA가 제공하는 공통 인터페이스를 사용해 보자.

📌 순수 JPA

@Repository
public class MemberJpaRepository {

    @PersistenceContext
    private EntityManager em;
    
    public Member save(Member member) {
        em.persist(member);
        return member;
    }
    public void delete(Member member) {
        em.remove(member);
    }
   ...
}

📌 스프링 데이터 JPA

public interface MemberRepository extends JpaRepository<Member, Long>{

}
  • 구현체 없이 인터페이스로만 동작하는 이유 : 스프링 데이터 JPA가 구현 클래스(Proxy)를 대신 생성해 준다.

  • Repository 애노테이션 생략 가능한 이유 : 컴포넌트 스캔을 스프링 데이터 JPA가 자동으로 처리해 준다.


공통 인터페이스 기능

스프링 데이터 JPA는 간단한 CRUD 기능을 공통으로 처리하는 JpaRepository 인터페이스를 제공한다.

스프링 데이터 JPA 이외에도 스프링 데이터 MongoDB, 스프링 데이터 Redis등이 존재

제네릭 타입

  • T : 엔티티
  • ID : 엔티티의 식별자 타입

주요 메서드

  • save(S) : 새로운 엔티티는 저장하고 이미 있는 엔티티는 병합
  • delete(T) : 엔티티 하나를 삭제한다. 내부에서 EntityManager.remove() 호출
  • findById(ID) : 엔티티 하나를 조회한다. 내부에서 EntityManager.find() 호출
  • getOne(ID) : 엔티티를 프록시로 조회한다. 내부에서 EntityManager.getReference() 호출
  • findAll(…) : 모든 엔티티를 조회한다. 정렬( Sort )이나 페이징( Pageable ) 조건을 파라미터로 제공 가능

쿼리 메서드 기능

메서드 이름으로 쿼리 생성

스프링 데이터 JPA는 메서드 이름을 분석해서 JPQL을 생성하고 실행한다.

public interface MemberRepository extends JpaRepository<Member, Long> {

	List<Member> findByUsernameAndAgeGreaterThan(String username, int age);
}

📌 쿼리 메서드 필터 조건
스프링 데이터 JPA 공식 문서 참고


📌 스프링 데이터 JPA가 제공하는 쿼리 메소드 기능

  • 조회 : find…By ,read…By ,query…By get…By,
    • 참고 문서
    • : findHelloBy 처럼 ...에 식별하기 위한 내용(설명)이 들어가도 된다.
  • COUNT : count…By 반환타입 long
  • EXISTS : exists…By 반환타입 boolean
  • 삭제 : delete…By, remove…By 반환타입 long
  • DISTINCT : findDistinct, findMemberDistinctBy
  • LIMIT : findFirst3, findFirst, findTop, findTop3

이 기능은 엔티티의 필드명이 변경되면 인터페이스에 정의한 메서드 이름도 꼭 함께 변경해야 한다. 그렇지 않으면 애플리케이션을 시작하는 시점에 오류가 발생한다. 이렇게 애플리케이션 로딩 시점에 오류를 인지할 수 있는 것이 스프링 데이터 JPA의 매우 큰 장점이다!!


JPA NamedQuery

스프링 데이터 JPA를 활용하여 앞서 다룬 JPA의 Named 쿼리를 호출하는 방법이다. 실무에서는 이 방식을 거의 사용하지 않는다. 대신 아래의 @Query를 사용한다.


@Query, 리포지토리 메소드에 쿼리 정의하기

public interface MemberRepository extends JpaRepository<Member, Long> {

//엔티티 조회
@Query("select m from Member m where m.username= :username and m.age = :age")
List<Member> findUser(@Param("username") String username, @Param("age") int age);

}
//단순히 값 한개를 조회
@Query("select m.username from Member m")
List<String> findUsernameList();

//DTO로 직접 조회
@Query("select new study.datajpa.dto.MemberDto(m.id, m.username, t.name) " +
 "from Member m join m.team t")
List<MemberDto> findMemberDto();
  • 실행할 메서드에 정적 쿼리를 직접 작성하는 방법이다.
  • 애플리케이션 실행 시점에 문법 오류를 발견할 수 있다.

중간 정리

1. 공통 인터페이스 : 일반적인 CRUD를 해결
2. 메서드 이름으로 쿼리 생성 : 실무에서 많이 씀(간단한 경우)
3. JPA NamedQuery : 실무에서 사용 안함
4. @Query : 실무에서 많이 씀(복잡한 경우)

추가로 단순 값이나 DTO 조회시 @Query 사용


파라미터 바인딩

파라미터 바인딩에는 위치 기반과 이름 기반이 있다.

select m from Member m where m.username = ?0 //위치 기반
select m from Member m where m.username = :name //이름 기반

코드 가독성과 유지 보수를 위해 이름 기반 파라미터 바인딩을 사용하자!

ex

public interface MemberRepository extends JpaRepository<Member, Long> {

	@Query("select m from Member m where m.username = :name")
	Member findMembers(@Param("name") String username);
	
    //컬렉션 파라미터 바인딩
    @Query("select m from Member m where m.username in :names")
	List<Member> findByNames(@Param("names") List<String> names);
}

반환 타입

List<Member> findListByUsername(String name); //컬렉션
Member findMemberByUsername(String name); //단건
Optional<Member> findOptionalByUsername(String name); //단건 Optional

조회 결과가 많거나 없으면?

  • 컬렉션
    • 결과 없음 : 빈 컬렉션 반환
  • 단건 조회
    • 결과 없음 : null 반환
    • 결과가 2건 이상 : javax.persistence.NonUniqueResultException 예외 발생

페이징과 정렬

  • 반환 타입

    • Page : 추가 count 쿼리 결과를 포함하는 페이징

      public interface Page<T> extends Slice<T> {
      	int getTotalPages(); //전체 페이지 수
      	long getTotalElements(); //전체 데이터 수
      	<U> Page<U> map(Function<? super T, ? extends U> converter); //변환기
      }
    • Slice : 추가 count 쿼리 없이 다음 페이지만 확인 가능(내부적으로 limit + 1조회)

      public interface Slice<T> extends Streamable<T> {
      	int getNumber(); //현재 페이지
      	int getSize(); //페이지 크기
      	int getNumberOfElements(); //현재 페이지에 나올 데이터 수
      	List<T> getContent(); //조회된 데이터
      	boolean hasContent(); //조회된 데이터 존재 여부
      	Sort getSort(); //정렬 정보
      	boolean isFirst(); //현재 페이지가 첫 페이지 인지 여부
      	boolean isLast(); //현재 페이지가 마지막 페이지 인지 여부
      	boolean hasNext(); //다음 페이지 여부
      	boolean hasPrevious(); //이전 페이지 여부
      	Pageable getPageable(); //페이지 요청 정보
      	Pageable nextPageable(); //다음 페이지 객체
      	Pageable previousPageable();//이전 페이지 객체
      	<U> Slice<U> map(Function<? super T, ? extends U> converter); //변환기
      }
      	```
    • List : 추가 count 쿼리 없이 결과만 반환


  • 페이징과 정렬 사용 예제

    검색 조건 : 나이가 10살
    정렬 조건 : 이름으로 내림차순
    페이징 조건 : 첫 번째 페이지, 페이지당 보여줄 데이터는 3건

    • page 사용 예제 정의 코드
      public interface MemberRepository extends Repository<Member, Long> {
      	Page<Member> findByAge(int age, Pageable pageable);
      }
    • page 사용 예제 실행 코드
      //페이징 조건과 정렬 조건 설정
      @Test
      public void page() throws Exception {
      	//given
      	memberRepository.save(new Member("member1", 10));
      	memberRepository.save(new Member("member2", 10));
      	memberRepository.save(new Member("member3", 10));
      	memberRepository.save(new Member("member4", 10));
      	memberRepository.save(new Member("member5", 10));
      
      	//when
      	PageRequest pageRequest = PageRequest.of(0, 3, Sort.by(Sort.Direction.DESC,"username"));
      	Page<Member> page = memberRepository.findByAge(10, pageRequest);	//pagealbe은 인터페이스, pageRequest는 그 구현체
      
      	//then
      	List<Member> content = page.getContent(); //조회된 데이터
      	assertThat(content.size()).isEqualTo(3); //조회된 데이터 수
      	assertThat(page.getTotalElements()).isEqualTo(5); //전체 데이터 수
      	assertThat(page.getNumber()).isEqualTo(0); //페이지 번호
      	assertThat(page.getTotalPages()).isEqualTo(2); //전체 페이지 번호
      	assertThat(page.isFirst()).isTrue(); //첫번째 항목인가?
      	assertThat(page.hasNext()).isTrue(); //다음 페이지가 있는가?
      }

  • count 쿼리 분리
    count 쿼리를 다음과 같이 분리할 수 있다. 성능 최적화를 위하여 복잡한 sql을 사용할 때 count 쿼리를 분리하자. 실무에서 매우 중요하다!!

    @Query(value = “select m from Member m”,
    	countQuery = “select count(m.username) from Member m”)
    Page<Member> findMemberAllCountBy(Pageable pageable);
  • DTO 변환
    페이지를 유지하면서 엔티티를 DTO로 변환할 수 있다.

    Page<Member> page = memberRepository.findByAge(10, pageRequest);
    Page<MemberDto> dtoPage = page.map(m -> new MemberDto());

벌크성 수정 쿼리

벌크성 수정, 삭제 쿼리는 @Modifying 어노테이션을 사용해야 한다. 추가로 벌크성 쿼리를 실행하고 나서 영속성 컨텍스트를 초기화해야 한다. 왜냐하면 벌크 연산은 영속성 컨텍스트를 무시하고 실행하기 때문에 영속성 컨텍스트에 있는 엔티티의 상태와 DB의 엔티티 상태가 달라질 수 있기 때문이다. @Modifying(clearAutomatically = true)를 통해 초기화하자. 이 옵션의 기본값은 false다.

@Modifying(clearAutomatically = true)
@Query("update Member m set m.age = m.age + 1 where m.age >= :age")
int bulkAgePlus(@Param("age") int age);

@EntityGraph

연관된 엔티티를 한번에 조회하는 방법에는 페치 조인 외에도 EntityGraph가 있다. NamedEntityGraph도 있지만 잘 사용하지 않는다.

  • 간단한 경우 : JPQL 페치 조인
@Query("select m from Member m left join fetch m.team")
List<Member> findMemberFetchJoin();
  • 복잡한 경우 : EntityGraph
//공통 메서드 오버라이드
@Override
@EntityGraph(attributePaths = {"team"})
List<Member> findAll();

//JPQL + 엔티티 그래프
@EntityGraph(attributePaths = {"team"})
@Query("select m from Member m")
List<Member> findMemberEntityGraph();

//메서드 이름으로 쿼리에서 특히 편리하다.
@EntityGraph(attributePaths = {"team"})
List<Member> findByUsername(String username)

JPA Hint & Lock

  • JPA Hint : 변경 감지는 원본과 수정본(snapshot) 두개를 동시에 관리한다. 따라서 만약 변경하지 않고 조회만 하고 싶을 때는 변경 감지가 작동하는 것이 비효율적이다. 따라서 만약 조회만을 목적으로 할 때 이를 최적화할 수 있는 방법이 바로 Hint다.

    @QueryHints(value = @QueryHint(name = "org.hibernate.readOnly", value ="true"))
    Member findReadOnlyByUsername(String username);

    성능 최적화 효과가 그렇게 크지 않으므로 일일이 다 적용하기 보다는 성능테스트를 해보고 필요할때 사용하자.

  • Lock
    자세한 내용은 JPA책 참고하자.

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    List<Member> findByUsername(String name);

확장 기능

사용자 정의 리포지토리 구현

스프링 데이터 JPA 리포지토리는 인터페이스만 정의하고 구현체는 스프링이 자동으로 생성해 준다. 만약 메서드 이름 방식이나 @Query 방식으로 해결이 안 되어 JPA 직접 사용(EntityManager), 스프링 JDBC Template, Querydsl 방식을 통해 인터페이스의 메서드를 직접 구현하고 싶을 때는 사용자 정의 리포지토리를 구현하면 된다. 사용법은 아래와 같다.

사용자 정의 인터페이스

public interface MemberRepositoryCustom {
	List<Member> findMemberCustom();
}

사용자 정의 인터페이스 구현 클래스

규칙 : 사용자 정의 인터페이스 명 + Impl or 리포지토리 인터페이스 이름 + impl
보통 전자를 선호한다.

@RequiredArgsConstructor
public class MemberRepositoryCustomImpl implements MemberRepositoryCustom {

	private final EntityManager em;

	@Override
	public List<Member> findMemberCustom() {
		return em.createQuery("select m from Member m")
			.getResultList();
	}
}

사용자 정의 인터페이스 상속

public interface MemberRepository extends JpaRepository<Member, Long>, MemberRepositoryCustom {

}

실무에서는 주로 QueryDSL이나 스프링 JdbcTemplate를 함께 사용할 때 사용자 정의 리포지토리 기능을 사용한다. 하지만 무작정 사용자 리포지토리가 필요한 것은 아니다 그냥 임의의 리포지토리를 만들어도 된다. 만약 사용자 정의 리포지토리를 무작정으로 사용하면 기존 리포지토리의 규모가 커지고 복잡해질 수 있다.


Auditing

엔티티를 생성, 변경할 때 변경한 사람과 시간을 추적하고 싶을 때 사용한다.

순수 JPA

@MappedSuperclass
@Getter
public class JpaBaseEntity {

	@Column(updatable = false)
	private LocalDateTime createdDate;
	private LocalDateTime updatedDate;

	@PrePersist
	public void prePersist() {
		LocalDateTime now = LocalDateTime.now();
		createdDate = now;
		updatedDate = now;
	}

	@PreUpdate
	public void preUpdate() {
		updatedDate = LocalDateTime.now();
	}
}

적용하고자 하는 엔티티에서 JpaBaseEntity를 상속하면 된다.

public class Member extends JpaBaseEntity { 
	...

}

스프링 데이터 JPA

먼저 스프링 부트 설정 클래스에 @EnableJpaAuditing를 적용한다. 실무에서 대부분의 엔티티는 등록시간, 수정시간이 필요하지만 등록자, 수정자는 없을 수도 있다. 그래서 다음과 같이 Base 타입을 분리하고, 원하는 타입을 선택해서 상속한다.


@EntityListeners(AuditingEntityListener.class)
@MappedSuperclass
@Getter
public class BaseTimeEntity {
 
	@CreatedDate
	@Column(updatable = false)
	private LocalDateTime createdDate;
 
	@LastModifiedDate
	private LocalDateTime lastModifiedDate;
}
@EntityListeners(AuditingEntityListener.class)
@MappedSuperclass
@Getter
public class BaseEntity extends BaseTimeEntity {

	@CreatedBy
	@Column(updatable = false)
	private String createdBy;

	@LastModifiedBy
	private String lastModifiedBy;

만약 등록자, 수정자가 필요하다면 이들을 처리해 주는 AuditorAware를 스프링 빈으로 등록해야 한다. 아래에서는 편의상 UUID를 사용하지만 실무에서는 세션 정보나 스프링 시큐리티 로그인 정보에서 ID를 받는다.

@SpringBootApplication
@EnableJpaAuditing
public class DataJpaApplication {

	public static void main(String[] args) {
		SpringApplication.run(DataJpaApplication.class, args);
	}

	@Bean
	public AuditorAware<String> auditorProvider() {
		return () -> Optional.of(UUID.randomUUID().toString());
	}
	
}

Web 확장 - 도메인 클래스 컨버터

HTTP 파라미터로 넘어온 엔티티의 아이디로 엔티티 객체를 찾아서 바인딩한다.

도메인 클래스 컨버터 사용 전

@RestController
@RequiredArgsConstructor
public class MemberController {

	private final MemberRepository memberRepository;

	@GetMapping("/members/{id}")
	public String findMember(@PathVariable("id") Long id) {
		Member member = memberRepository.findById(id).get();
		return member.getUsername();
	}
}

도메인 클래스 컨버터 사용 후

단순 조회용으로만 사용해야 한다는 단점이 있다. 권장하지 않음.

@RestController
@RequiredArgsConstructor
public class MemberController {

	private final MemberRepository memberRepository;

	@GetMapping("/members/{id}")
	public String findMember(@PathVariable("id") Member member) {
		return member.getUsername();
	}
}

Web 확장 - 페이징과 정렬

앞서 리포지토리에서 페이징과 정렬 기능을 사용하는 방법을 공부했다. MVC에서도 마찬가지로 스프링 데이터가 편리한 방법을 제공한다.

페이징과 정렬 예제

@GetMapping("/members")
public Page<Member> list(Pageable pageable) {
	Page<Member> page = memberRepository.findAll(pageable);
	return page;
}

요청 파라미터

스프링 부트가 파라미터들을 PageRequest 객체로 생성해서 주입해 준다.

예) /members?page=0&size=3&sort=id,desc&sort=username,desc
page : 현재 페이지, 0부터 시작한다.
size : 한 페이지에 노출할 데이터 건수
sort : 정렬 조건을 정의한다. 예) 정렬 속성,정렬 속성...(ASC | DESC), 정렬 방향을 변경하고 싶으면 sort 파라미터 추가 ( asc 생략 가능)


기본값

기본 페이지 사이즈는 20, 최대 페이지 사이즈는 2000으로 설정되어 있다. 스프링 부트의 글로벌 설정이나 @PageableDefault 어노테이션을 사용해 바꿀 수 있다.


접두사

  • 페이징 정보가 둘 이상이면 접두사로 구분
  • @Qualifier 에 접두사명 추가 "{접두사명}_xxx”
  • 예제: /members?member_page=0&order_page=1
    public String list(
    @Qualifier("member") Pageable memberPageable,
    @Qualifier("order") Pageable orderPageable, ...

Page 내용을 DTO로 변환하기

엔티티를 API로 노출하면 다양한 문제가 발생한다. 그래서 엔티티를 꼭 DTO로 변환해서 반환해야 한다. Page는 map() 을 지원해서 내부 데이터를 다른 것으로 변경할 수 있다.

  • Member DTO

    @Data
    public class MemberDto {
    	private Long id;
    	private String username;
    
    	public MemberDto(Member m) {
    		this.id = m.getId();
    		this.username = m.getUsername();
       }
    }

  • Page.map() 사용
    @GetMapping("/members")
    public Page<MemberDto> list(Pageable pageable) {
    	Page<Member> page = memberRepository.findAll(pageable);
    	Page<MemberDto> pageDto = page.map(MemberDto::new);
    	return pageDto;
    }

Page를 1부터 시작하기
스프링 데이터는 Page를 0부터 시작한다. 만약 1부터 시작하려면 2가지 방법이 있는데 자세한 내용은 강의나 책 참고하자.


스프링 데이터 JPA 분석

스프링 데이터 JPA의 공통 인터페이스 JpaRepository는 구현체로 SimpleJpaRepository를 가진다.

@Repository
@Transactional(readOnly = true)
public class SimpleJpaRepository<T, ID> ...{

	@Transactional
	public <S extends T> S save(S entity) {

		if (entityInformation.isNew(entity)) {
			em.persist(entity);
			return entity;
		} else {
			return em.merge(entity);
		}
	}
 	...
}

@Repository
1. JPA 예외를 스프링이 추상화한 예외로 변환, 하부 기술을 Jdbc에서 Jpa로 바꿔도 예외를 처리하는 매커니즘은 동일
2. 스프링 빈 컴포넌트 스캔 대상


@Transactional
JPA의 모든 데이터 변경은 트랜잭션 안에서 일어나야 한다. 서비스 계층의 트랜잭션 유무에 따라 두 가지 경우가 있다.

  • if 서비스 계층에서 트랜잭션을 시작, 리파지토리는 해당 트랜잭션을 전파 받아서 사용
  • if 서비스 계층에서 트랜잭션을 시작 X, 구현체가 @Transactional을 가지고 있어서 리포지토리에서 트랜잭션을 시작함

따라서 스프링 데이터 JPA를 사용할 때 트랜잭션이 없어도 데이터 등록, 변경을 자유롭게 할 수 있다.


save() 메서드

save() 메서드는 새로운 엔티티면 persist, 새로운 엔티티가 아니면 merge를 한다. entityInformation가 새로운 엔티티를 판단하는 기본 전력은 다음과 같다.

  • 식별자가 객체일 때 null로 판단
  • 식별자가 자바 기본 타입일 때 0으로 판단

JPA 식별자 생성 전략이 @GenerateValue 면 save() 호출 시점에 식별자가 없으므로 새로운 엔티티로 인식해서 정상 동작한다. 하지만 JPA 식별자 생성 전략이 @Id 만 사용한 직접 할당이면 이미 식별자 값이 있는 상태로 save()를 호출한다. 따라서 이 경우 merge() 가 호출된다. merge()는 매우 비효율적이므로 사용하지 않는 것이 좋다. 이 경우에는Persistable를 사용해서 새로운 엔티티 확인 여부를 직접 구현하게는 효과적이다. 참고로 등록시간@CreatedDate을 조합해서 사용하면 이 필드로 새로운 엔티티 여부를 편리하게 확인할 수 있다. 여기서는 @CreatedDate에 값이 없으면 새로운 엔티티로 판단한다.


Persistable 구현

@Entity
@EntityListeners(AuditingEntityListener.class)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Item implements Persistable<String> {

	@Id
	private String id;

	@CreatedDate
	private LocalDateTime createdDate;

	public Item(String id) {
		this.id = id;
	}
	
    @Override
	public String getId() {
		return id;
	}
 
	@Override
	public boolean isNew() {
		return createdDate == null;
	}
}

나머지 기능들

Specifications(명세)

스프링 데이터 JPA는 JPA Criteria를 활용해서 이 개념을 사용할 수 있도록 지원한다. 하지만 실무에서는 JPA Criteria를 안쓴다. 대신에 QueryDSL을 사용하자.


Query By Example

실무에서는 Query By Example 안쓴다. 대신 QueryDSL을 사용하자.


Projections

Projections는 DB에서 엔티티 대신 원하는 DTO를 조회할 때 사용한다. 예를 들어 회원의 이름만 조회하고 싶을 때 사용하면 좋다. 실무에서는 단순할 때만 사용하고, 조금만 복잡해지면 QueryDSL을 사용하자.


인터페이스 기반 closed Projections

public interface MemberRepository extends JpaRepository<Member, Long> {

	List<UsernameOnly> findProjectionsByUsername(String username);
}

프로퍼티 형식(getter)의 인터페이스를 제공하면, 구현체는 스프링 데이터 JPA가 제공해 준다.

public interface UsernameOnly {
	String getUsername();
}

인터페이스 기반 Open Projections

스프링의 SpEL 문법을 사용한다. JPQL SELECT절 최적화가 안된다는 단점이 있다.

public interface UsernameOnly {
 
	@Value("#{target.username + ' ' + target.age + ' ' + target.team.name}")
	String getUsername();
}

클래스 기반 Projection
다음과 같이 인터페이스가 아닌 구체적인 DTO 형식도 가능하다.
생성자의 파라미터 이름으로 매칭한다.

public class UsernameOnlyDto {

	private final String username;
 
	public UsernameOnlyDto(String username) {
		this.username = username;
	}

	public String getUsername() {
		return username;
	}
}

동적 Projections

다음과 같이 Generic type을 주면, 동적으로 프로젝션 데이터 번경 가능하다.

<T> List<T> findProjectionsByUsername(String username, Class<T> type);

List<UsernameOnly> result = memberRepository.findProjectionsByUsername("m1", UsernameOnly.class);

네이티브 쿼리

JPA는 SQL을 직접 사용할 수 있는 기능을 제공하는데 이를 네이티브 쿼리라 한다. 실무에서는 거의 안쓰고 대신 QueryDSL, JdbcTemplate, myBatis 등으로 해결한다. 최근에 스프링 데이터 Projections 활용 기술이 나왔는데 그냥 이런 기술들이 있구나 정도로 넘어가자.

profile
Problem Solving과 기술적 의사결정을 중요시합니다.

0개의 댓글