인프런 워밍업 클럽 2기 - 백엔드 프로젝트 (Spring, Kotlin) 2주차 발자국

인프런 워밍업 클럽 2기 - 백엔드 프로젝트 (Spring, Kotlin) 2주차 발자국

일주일간 학습한 내용 요약

개발 - Domain

프로젝트 생성

Jar로 해야지 스프링부트에서 제공하는 내장 키트를 사용할 수 있다.

Dependensies: 프로젝트에서 쓸 외부 라이브러리들을 추가해 주는 작업

6개의 라이브러리 추가

  1. Spring web: MVC사용 등

  2. Thymeleaf: 템플릿과 데이터를 합쳐서 최종적으로 완성된 html 파일을 만들어준다. (없으면 개발자가 html 파일까지 코드를 짜야함)

  3. Spring Data JPA: JPA에 껍데기를 씌서 사용성을 높임

  4. My SQL Driver

  5. H2 Database: 인메모리 DB로 스프링이 켜질때 같이 켜짐(스프링과 같은 메모리 사용), 꺼질때 데이터 사라짐

  6. Validation: 검증기능

그외

Spring Security: 로그인 기능에 사용하지만 지금 설치하면 스프링 킬때마다 로그인 해야해서 일단 설치 제외 

IntelliJ 설정

  • 프로젝트 스트럭쳐

  • 프리퍼런시스

 

Git과 Github

Git 용어

  • commit: 현재 작업한 내용을 하나의 버전으로 반영(저장)

  • rollback:작업한 내용 이전 버전으로 되돌리기

  • branch: 하나의 프로젝트에서 독립적, 병렬적인 버전으로 가지같이 여러명이 동시에 개발이 가능하게 만듬

  • merge: 서로 다른 branch를 합치는 동작

  • conflict(충돌): merge할 때 하나의 파일이 두 브랜치에서 수정이 발생해, 어떤 수정본을 반영할지 알 수 없는 상황

  • repository: github의 저장소 (=remote repository)

     

  • push: 원격 저장소로 브랜치를 업로드 하는 동작

  • pull: 다운받는 것

Git 명령어

  • git status, add, pull, clone, push, commit -m

터미널에서
'pwd' 입력 => 폴더의 경로 확인
'git init' 입력 => 깃 폴더 초기화
'git status'입력 => 깃에서 관리하지 않는 파일들이 빨간색으로 표시됨. 그 중 관리하지 않아도 된는 파일들을 배제하고 등록해줌.

  • '.gitignore' 파일에 입력해서 배제 가능함 => gitignore.io 에서 목록 개발환경 입력 후 복사 가능

  • git add README.md : 특정 파일을 기초적 대상으로 추가하는 명령어

  • git commit -m "first commit" : 현재 변경된 내용을 새 버전으로 반영하는 명령어


    '-m' 옵션을 통해 ""(쌍따옴표)안에 있는 내용을 커밋 메시지로 입력

     

프로젝트 환경 변수 설정

데이터 소스와 jpa설정 => 설정해놓은 값을 복붙함.

위와 관련된 중요 개념
이런 설정 값들은 보통 상수(=변하지 않는 값)다.

  • DB url 등

자바 코드에 " "(literal, 문자열 방식)으로 관리해도 되지만, 같은 값을 여러 클래스에서 사용할 때, 값이 수정되면 모든 클래스에서 사용한 값들을 다 찾아서 수정해줘야 한다. 이때, 하나라도 놓치면 에러가 난다.

실제 운영할 때는 개발용 서버, 운영용 서버, 개발용 DB, 운영용 DB로 나눠서 사용한다.

서로 다른 서버 컴퓨터에서 똑같은 프로그램이 돌아가는데 서로 다른 DB서버에 붙어있다. 개발DB와 운영DB의 주소는 다르다 => 환경변수(=환경마다 바뀌는 값)

개발서버에게 개발DB URL을, 운영서버에게 운영DB URL을 알려줘야하는데 " "(문자열 방식)으로는 관리가 어렵다.

Spring Profile과 application.yml

  • Spring Profile: 스프링은 돌아가는 애플리케이션의 프로필을 정의하는 기능을 제공, 스프링을 실행시키는 시점에서 환경변수로 정의 가능

    • 개발 서버에 스프링 프로젝트를 띄울 때 dev라는 프로필로 돌릴거라고 지정하고, 운영서버prod라는 프로필로 지정하면, 각자 dev, prod로 세팅이 된다. 아무것도 세팅을 안하면 기본값은 default라는 이름으로 돈다. No active profile set, falling back to 1 default profile: "default"

       

  • application.yml: 스프링은 프로필마다 환경변수를 설정하는 기능을 제공,

     

    • application.properties: YML 파일과 똑같은 기능을 한다. (문법이 좀 다르다)

    • 기존에 있는 properties 파일을 yml로 변경. => application-default.yml

    • yml 파일을 복사해서 application-docker.yml 을 만듦.

    • application-{Profile} 형식으로 위와 같이 파일을 네이밍 해주면, Profile에 따라 상수 값 설정이 가능하다.
      스프링이 실행될 때 프로필이 defaultapplication-default.yml 에서 환경변수를 가져오고, 프로필 이름이 dockerapplication-docker.yml 에서 환경변수를 가져온다.

    • 키-밸류 형식으로 등록할 수 있다. 같은 키에 값만 다르게 등록한 것. 소스 코드에는 String DATASOURE_URL_PROPERTY = "spring.datasource.url";로 등록해주면 프로필에 따라 각 키에 맵핑값을 찾아 각 DB와 연결하고 동작한다.

     

yml

  • jpa에 대한 설정
    open-in-view: false => 나중에 따로 설명
    show-sql: true => sql을 로그에 보이게 할지
    hibernate: ddl-auto: create => JPA 엔티티를 바탕으로 jpa에서 데이터베이스에 테이블을 새로 만들어주는 기능, 개발 테스트할 때는 써도 되지만 운영에서는 무조건 None으로.
    properties: hibernate: format_sql: false => sql 로그를 찍을 때 좀 더 보기 쉽고 이쁘게 만들어주는 것, 근데 한줄로 보이게 false처리
    # default_batch_fetch_size: 10 => 강의에서 따로 설명 예정

  • datasource에 대한 설정(docker.yml과 내용이 다름)
    url: jdbc:h2:mem:portfolio => db에 url을 알려주고
    username: sa
    password: => 접속하기 위해 필요한 사용자명과 pw알려준다.
    driver-class-name: org.h2.Driver

  • h2에 대한 설정(default.yml에만 있다. Mysql에는 아래와 같은 설정이 없기 때문)
    console: => H2에서 DB에 접속하기 위해 사용하는 H2콘솔
    enabled: true => 을 사용하고
    path: /h2-console => 어떤 경로로 접속할 건지 지정해주는 옵션

클래스 생성

도메인 패키지에서 개발할 클래스들을 미리 껍데기만 만듦

포트폴리오 패키지

  • 도메인 패키지

    • constant (in 상수 관련 클래스)

    • entity (in 총 11개의 클래스)

    • repository (in 총 8개의 인터페이스)

    • configuration (암호관련-나중에 만듦)

entity 패키지 (11개 클래스)

BaseEntity(추상클래스):모든 테이블들이 공통적으로 갖는 Created Date Time, Updated Date Time 컬럼들은 각 클래스에 직접 넣지 않고 상속을 활용할 예정

  • @MappedSupercass: 이 어노테이션이 있는 클래스를 상속 받는 엔티티 클래스가 이 클래스 안에 있는 필드들을 해당 엔티티에 있는 테이블의 컬럼과 맵핑 할 수 있다.

Achievement: BaseEntitiy클래스를 상속받는다.

  • @Entity: 이 어노테이션을 달아줘야 JPA에서 테이블과 맵핑되는 엔티티 클래스라는 것을 알 수 있다.

  • @Id: JPA엔티티에는 필수인 어노테이션, 필드 위에 입력. (var id가 하나의 필드) @Id를 붙여줘야지 이 필드가 PK라는 것을 알 수 있다.
    => match case(설정)을 끄면 자동완성을 도와준다.

  • @

    GeneratedValue(strategy = GenerationType.{다양}: PK생성 전략을 정해준다. strategy 파라미터를 통해 정한다.
    {다양}
    TABLE: pk를 만들기 위한 테이블을 전용으로 만들어 PK생성(?)
    SEQUENCE: DB가 제공하는 순서대로 번호를 지정해주는 시퀀스라는 기능을 사용(MySQL에서는 사용불가)
    IDENTITY: 기본 키 생성을 DB에 위임. MySQL의 경우 Auto Increment라는 기능을 이용. => 이거로 사용
    AUTO: JPA가 내부 알고리즘을 따라 자동적으로 결정하는데 MySQL에서는 AUTO로 하면 앞의 TABLE을 사용한다.

  • @Column(name = "achievement_id"): 이 필드가 DB에서 어떤 이름을 가진 컬럼이랑 맵핑되는지 개발자가 직접 지정해주는 기능.
    안붙여도 필드는 CamelCase(isCamelCase), DB는 SnakeCase(is_snake_case)로 되어 있으면 알아서 맵핑 컬럼을 찾아준다.
    테이블 pk는 테이블명_id로 지정하고, 코틀린 엔티티에서는 필드명을 id로만 지정. (나중에 이해 안가면 강의 다시 듣기 (7:00) )
    엔티티 인스턴스를 사용할 때
    val achievement: Achievement로 변수명을 해줌. 필드명을 id로 줄이지 않으면 achievement.achievementId로 id를 조회해야 함.
    achievement.id로 직관적이고 보기도 좋게 사용하고 싶음
    => 때문에
    @Column(name = "achievement_id")

     

    var id: Long? = null
    로 지정
    자료형 뒤에 ?를 붙이면 null이 허용된다는 의미, 코틀린은 자바보다 null에 대해 엄격하다.
    id는 엔티티를 처음 생성할 때 들어가지 않고 이 엔티티를 DB에 저장할 때 DB에서 생성해 주는 값이기 때문에 인스턴스를 처음 만든 순간에는 null일 수 밖에 없다.

Achievement 클래스를 복사해서 다른 클래스들을 만든다. (@Column의 name등 바꾸기)

 

repository 패키지 (8개 인터페이스)

Spring Data JPA Repository

  • Repository: DB 접근하는 역할

  • Spring Data JPA: 스프링에서 jpa를 좀더 쉽게 쓰기 위해 한번 랩핑한 라이브러리

  • 인터페이스를 추가하는 것만으로 DB CRUD와 관련된 기본적인 기능을 사용 가능

  • 각 엔티티에 대응해 interface로 각각 repository를 만들어야한다.

  • BaseEntity 클래스 제외

  • 스프링을 시작할 때 SpringDataJPA에서 인터페이스를 보고 알아서 repository 클래스를 만든다.

 AchievementRepository

  • interface AchievementRepository : JpaRepository<Achievement, Long>
    JpaRepository<Achievement, Long>를 상속받음.
    <>을 Generic으로 명칭

 나머지 엔티티에 대응하는 레퍼지토리 만들기

  • ...Detail 클래스에 대응하는 repository는 안만든다. JPA가 연관관계를 가진 엔티티를 통해서 엔티티를 불러올 수 있기 때문

  • ...Skill 클래스는 따로 만들어준다 => 왜? 뭔가 다르데

     


    constant 패키지
    SkillType: enum 클래스 => 상수값(언어, 프레임워크, BD, Tool)

 

엔티티 개발 - 연관관계 없음

BaseEntity.kt

  • @CreatedDate: JPA엔티티가 생성된 시간을 자동으로 세팅

  • @Column(nullable = false, updatable = false): 지난번 Name 파라미터를 이용해 필드와 맵핑될 Column의 이름을 별도로 지정해주는 기능(@Column(name = "achievement_id")) 설정함. 그것과는 다른 기능을 설정. 위 내용은 null일 수 없고, 변경 불가능 하다는 뜻.

     

(다른 엔티티와) 연관관계가 없는 엔티티

  • Skill 같은 경우, 프로젝트와 프로젝트 스킬을 통해서 연관관계를 가지지만, 스킬을 통해 프로젝트에 직접 접근하는 일이 없다 -> 때문에 연관관계가 없는 엔티티와 다를게 없다.

  • 엔티티 같은 경우, 연관관계에 상관없이 생성자를 이용해 처음 인스턴스를 생성할 때 필요한 값들을 전부 받으려고 한다.


    때문에 기본 생성자부터 만든다


생성자(영어: constructor, 혹은 약자로 ctor)는 객체 지향 프로그래밍에서 객체의 초기화를 담당하는 서브루틴을 가리킨다. 생성자는 객체가 처음 생성될 때 호출되어 멤버 변수를 초기화하고, 필요에 따라 자원을 할당하기도 한다. 객체의 생성 시에 호출되기 때문에 생성자라는 이름이 붙었다.

[위키백과]


Achievement.kt

  • 기본 생성자를 만든다

  • id 아래에 필드들을 만든다. -> 생성자에서 받은 값들을 넣어준다. (초기화한다.)

  • Introduction.kt와 Link.kt도 비슷하다.

Skill.kt

  • 생성자 중 type: String(일단은 문자열로 받음)
    => 데이터를 처음 만들 때, 어드민 프론트에서 데이터를 받아서 세팅을 해주는데, 어드민에서는 이런 타입 같은 것을 알 방법이 없기에 문자로 보냄
    => 문자로 받고 생성자 내부적으로 타입 스트링에 맞는 스킬 타입을 찾아 필드에 넣어줄 것임.

  • var type: SkillType = SkillType.valueOf(type)

  • SkillType.kt에서 문자열과 일치하는 enum을 찾아서 리턴해줌.

  • jpa에서 활용하려면 좀 더 지정해줘야 함.

  • @Column(name = "skill_type")
    (type을 예약어로 쓰는 DB가 있기 때문에 테이블 컬럼명으로 'type' 쓰는 것을 지양해야 함.)

  • @Enumerated(value = EnumType.STRING)
    자료형이 enum클래스일 때 쓰는 어노테이션.

    • EnumType.{STRING|ORDINAL} 두 개 중 선택 가능
      ORDINAL: enum이 선언된 순서대로 1, 2, 3...의 값을 DB에 넣어줌.
      1) DB를 봤을 때 직관적으로 이 데이터의 실질적 의미를 알기 어렵다.
      2) 어떤 개발자가 enum의 순서를 바꿨을 때, 데이터의 정합성이 깨짐
      STRING: enum의 이름 그대로 DB에 넣음(지정 필수)(DB의 용량을 약간 더 차지하는 단점 존재)

HttpInterface.kt

  • http 요청 정보를 저장하는 엔티티

  • class HttpInterface(httpServletRequest: HttpServletRequest)
    : 스프링에서 요청을 받을 때 그 request의 정보를 여기에 담아서 준다. => 클라이언트 정보를 꺼낸다.

  • var cookies: String? = httpServletRequest.cookies?.map

    {"${it.name}:${it.value}"

    }?.toString():

    • .map{ }cookies라는 객체가 배열인데 안의 것들을 하나씩 순차적으로 돌면서 {중괄호}안에 들어간 함수대로 변환해주는 기능. it은 cookies 객체. cookies안에 name과 value가 있어 중괄호 안의 방식으로 포맷팅 되어진다.
      => 쿠키에는 이용자가 본 내용, 상품 구매 내역, 신용카드 번호, 아이디(ID), 비밀번호 IP 주소 등이 배열로 담겨 있어 위 작업은 그 중 name과 value를 꺼내는 동작이다.(?)

    • .toString()으로 문자열로 바꾼다. => "name:value"


HTTP 쿠키(HTTP cookie)란 웹 서버에 의해 사용자의 컴퓨터에 저장되는, '이름을 가진 작은 크기의 데이터'이다. 인터넷 사용자가 어떠한 웹사이트를 방문할 경우 사용자의 웹 브라우저를 통해 인터넷 사용자의 컴퓨터나 다른 기기에 설치되는 작은 기록 정보 파일을 일컫는다. 쿠키, 웹 쿠키, 브라우저 쿠키라고도 한다. 이 기록 파일에 담긴 정보는 인터넷 사용자가 같은 웹사이트를 방문할 때마다 읽히고 수시로 새로운 정보로 바뀐다. 이 수단은 넷스케이프의 프로그램 개발자였던 루 몬툴리가 고안한 뒤로 오늘날 많은 서버 및 웹사이트들이 브라우저의 신속성을 위해 즐겨 쓰고 있다. (=> 신속성 = 서버크기 예측..?)

쿠키는 소프트웨어가 아니다. 쿠키는 컴퓨터 내에서 프로그램처럼 실행될 수 없으며 바이러스를 옮길 수도, 악성코드를 설치할 수도 없다. 하지만 스파이웨어를 통해 유저의 브라우징 행동을 추적하는데에 사용될 수 있고, 누군가의 쿠키를 훔쳐서 해당 사용자의 웹 계정 접근권한을 획득할 수도 있다.

[위키백과]


  • referer: nullable한 필드, http 요청 정보에서 referer을 가져온다. 구글을 통해 검색해 어떤 사이트에 들어갔을 때, google.com도메인이 referrer(조회인)가 되는 것
    웹 브라우저로 월드 와이드 웹을 서핑할 때, 하이퍼링크를 통해 각각의 사이트로 방문시 남는 흔적, 웹 사이트의 서버 관리자가 사이트 방문객이 어떤 경로로 자신의 사이트를 방문했는지 알아볼 때 유용, referer은 but 조작 가능, HTTP 리퍼러를 정의한 RFC에서 'referrer'을 'referer'로 잘못 입력한 것이 계속 사용됨

    [위키백과]

  • localAddr, remoteAddr, remoteHost:
    클라이언트와 관련된 ip 주소들

  • requestUri: 우리 서버에서 어떤 uri로 접속을 했는지, 메인이면 그냥 루트 or /, 프로젝트면 /프로젝트, resume면 /resume 로 어떤 uri로 접속했는지 그 정보가 들어온다. (referer과 다른 점은 어디에서 검색해서 사이트에 들어왔는지와, 사이트에서 이동 경로 추적 차이..?)
    통합 자원 식별자(Uniform Resource Identifier, URI)는 인터넷에 있는 자원을 나타내는 유일한 주소이다. URI의 존재는 인터넷에서 요구되는 기본조건으로서 인터넷 프로토콜에 항상 붙어 다닌다.
    URI의 하위개념으로 URL, URN 이 있다. [위키백과]

  • userAgent:
    사용하는 브라우저 정보, 크롬, 사파리, 모바일, 데스크탑 등등

 

엔티티 개발 - 연관관계 있음

Experience.kt
생성자에 초기값을 넣는다.
필드를 선언한다.
Experience Entity는 ExperienceDetail과 1:N의 관계
jpa에서는 List로 N쪽에 해당하는 필드를 가져올 수 있다.

@OneToMany(targetEntity = ExperienceDetail::class, 
           fetch = FetchType.LAZY, cascade = [CascadeType.ALL])
@JoinColumn(name = "experience_id")
var details: MutableList<ExperienceDetail> = mutableListOf()
  • @OneToMany(targetEntity = ExperienceDetail::class, fetch = FetchType.LAZY, cascade = [CascadeType.ALL]):

    • One Experience, Many ExperienceDetail. 아래 필드가 1대 다의 관계를 가지고 있다고 jpa에 알려주는 어노테이션

    • (targetEntity = ExperienceDetail::class,...): 어노테이션의 옵션 => targetEntity는 나중에 별도의 강의에서 설명 예정

    • fetch = FetchType.{EAGER|LAZY}:

      • EAGER은 더 열심히고 열정적인 경찰이래, 사건이 일어나면 용의자인 experience를 잡아야하는데, experienceDetail이 자식같은 관계니까 연관된 detail까지 다 잡아온다.
        개발자가 DB에서 experience만 조회하려고 했는데, detail까지 같이 인스턴스 안에 들어가 있다.
        그래서 EAGER는 쓰면 안된다. 오래걸린다. N+1의 문제인다(부모를 조회하려고 쿼리가 나가고 그다음 자식이 있다는 것 알고 자식을 조회하려고 쿼리가 N번 더 왔다갔다함, 부모 100명 조회 1번, 자식 100명 조회 100번 => 총 101번 쿼리 발송)

      • LAZY는 좀더 효율적이다. 부모를 조사하다가 자식도 혐의가 있을 때만 잡으러 간다.
        부모 엔티티에서 실제로 자식 엔티티 필드를 호출하는 그 순간에만 조회쿼리가 나간다. 호출한 부모 엔티티의 자식 엔티티를 모두 조회해야할 때에는 EAGER과 다를 바가 없기 때문에 근본적인 해결책은 안된다.

      • 처음부터 부모와 자식을 한꺼번에 조회하는 방법은 레포지토리 개발하면서 설명할 예정

    • cascade = [CascadeType.ALL]:
      영속성 콘테스트와 관련있는 개념, experience 엔티티가 영속성 콘테스트와 관련해서 발생하는 모든 변화에 자식 엔티티도 똑같이 적용할지 정해주는 옵션. ALL이면 모두 똑같이 적용한다는 뜻

  • @JoinColumn(name = "experience_id")
    맵핑에 기준이 되는 컬럼을 알려준다.

  • var details: MutableList<ExperienceDetail> = mutableListOf()
    mutableListOf: 빈 리스트를 만들어 준다.
    Mutable: '변할 수 있다' 라는 뜻

     

  • fun getEndYearMonth():
    종료연월을 각각 널체크하고 처리하면 서비스 코드가 복잡해지기 때문에 필요한 데이터를 한 번에 깔끔하게 서비스에서 가져올 수 있도록 엔티티 안에서 묶어줌

  • fun update(생성자 모두 받음): put...
    각각 호출해서 수정하는 것보다 update하나를 호출해서 모두 한꺼번에 데이터 변경 가능하게 함
    jpa는 엔티티의 데이터를 바꾸기만하면 트랜젝션이 끝날 때, 처음 데이터를 가져올 때 따로 백업했던 스냅샷과 지금 엔티티의 상태를 비교해서 수정된 부분이 있으면 알아서 업데이트를 날린다.

  • fun addDetails(details...):
    null 체크를 포함한 기본 방어 로직, 사용하는 쪽에서 깔끔한 디테일 데이터 추가 가능

ExperienceDetail.kt: 연관관계 없는 엔티티와 비슷
experienceDetail만 가지고는 experience를 찾을 수 없는 일대다 단방향 연관관계

  • fun update(content: String, isActive: Boolean):

Project.kt, ProjectDetail.kt 는 experience, ...detail과 비슷

@OneToMany(mappedBy = "project",
    fetch = FetchType.LAZY,
    cascade = [CascadeType.PERSIST])
var skills: MutableList<ProjectSkill> = mutableListOf()
  • @OneToMany(mappedBy = "project", fetch = FetchType.LAZY, cascade = [CascadeType.PERSIST]):

    • mappedBy:
      양방향 연관관계에서 연관관계의 주인을 지정할 때 사용. ProjectSkill.kt안에 var project를 추가하는데, 이 var project를 통해 맵핑이 되고 맵핑하는 것은 ProjectSkill(연관관계에서 주인)이다.

    • cascade = [CascadeType.PERSIST]:
      영속성 '전이'와 관련된 설정,
      cascade를 별도로 지정하지 않을 경우, Project 엔티티를 생성하고 save() 메소드를 호출해 영속성 컨텍스트에 persist한는 등 따로 persist를 해줘도 엔티티에 포함된 skills, 즉 ProjectSkills 엔티티들은 persist 되지 않는다.
      CascadeType.PERSIST를 지정해주면, Project 엔티티만 persist 해도, 거기 포함된 skills의 엔티티들이 모두 같이 persist가 된다.

      PERSIST 외에도 DETACH, MERGE, REMOVE, REFRESH 등의 상태를 적용할 수 있다.

  • var skills: MutableList<ProjectSkill> = mutableListOf()

ProjectSkill.kt: 다대일의 관계라서 프로젝트와 스킬을 각각 연결

@ManyToOne(targetEntity = Project::class, fetch = FetchType.LAZY)
@JoinColumn(name = "project_id", nullable = false)
var project: Project = project

@ManyToOne(targetEntity = Skill::class, fetch = FetchType.LAZY)
@JoinColumn(name = "skill_id", nullable = false)
var skill: Skill = skill

 

데이터베이스 초기화

프로필

  • 소개글 3줄

  • 깃허브, 링크드인 링크

  • 학력/경력(Experience)

  • 수상/자격증(Achievement)

  • 기술스택(Skill)

  • 프로젝트(Project)

    • 사용기술: 기술스택(Skill)과 다대다 관계

데이터 초기화 코드 작성

도메인 패키지 안에 DataInitializer.kt 생성 (개발 편의를 위해 임의로 만든 것임)

  • 총 6개의 repository에 의존한다.
    생성자로 6개 입력 private val achievementRepository: AchievementRepository 등등 => DataInitializer를 빈으로 등록하려면 생성자인 repository들도 빈으로 등록됨 => 이런식으로 스프링 초기화가 진행됨

    class DataInitializer( //이게 바로 생성자 주입
        private val achievementRepository: AchievementRepository,
        private val introductionRepository: IntroductionRepository,
        private val linkRepository: LinkRepository,
        private val skillRepository: SkillRepository,
        private val projectRepository: ProjectRepository,
        private val experienceRepository: ExperienceRepository
        )
  • @Component: 스프링에서 관리하는 인스턴스 => bean(빈)


    스프링 처음 실행시 컴포넌트 스캔 과정을 거침
    이때, 스프링에게 어떤 것을 빈으로 등록할지 알려주는 역할

    • 자바로 클래스를 사용하려면 개발자가 직접 생성자를 이용해서 클래스의 인스턴스를 만들어야하는데, 스프링 프레임워크가 개발자 대신 인스턴스 제어를 한다.
      다른 인스턴스에서 이렇게 만들어진 빈들을 사용하려면 그 인스턴스를 주입받아 사용한다. =>"의존성 주입DI" => DI 방법은 잠시 후 해볼 예정

       

    • 이런 빈들을 사용할 때에는 생성자, setter, 필드 주입등의 방식을 통해 의존성 주입을 받아 사용 가능

    • @Controller, @Service, @Repository:
      세 어노테이션에는 component의 기능이 포함되어 있다.

  • @Profile(value = ["default"]):
    스프링이 빈으로 등록하는데 프로필이 default일 때만 이 클래스를 생성해서 빈으로 등록한다. (개발자가 임의로 데이터 등록 못하게)

     

  • @PostConstruct:
    메인 메소드가 실행이 되면서 스프링을 구축한다. 이때 Spring DI가 컴포넌트 스캔을 해서 인스턴스(빈)를 생성하고 의존성을 주입한다. 이런 식으로 스프링 프로젝트를 construct(구축)한다. 이런 스프링 초기화 작업이 완료되면, PostConstruct가 붙은 메소드를 찾아서 한번 더 실행한다(이때는 빈들이 다 등록되어 있어서 필요한 빈을 찾아 사용가능, 그 빈들을 이용해 테스트 데이터를 초기화 함 ). 이게 끝나면 스프링 실행이 완료된 것.

  • fun initializerData(): //이게 아마 메인메소드

    • println(" "): => logger를 써라
      java의 'System.out.println'과 똑같다.
      내부적으로 Synchronized를 달고 있어 성능에 좋지 않다. (자원을 하나씩 순차적으로 여러 스레드가 사용하고 있고, 동시에 사용할 수 없다. 그래서 성능에 안좋고 운영에 절대 쓰면 안된다.)

    • logger:
      출력하려는 내용과 더불어 시간, 스레드 등 여러 정보들이 같이 출력됨. (때문에 강의할 때는 깔끔하게 보기위해 println을 쓸것임)

  • val achievements = mutableListOf<Achievement>(엔티티 2개 입력함):
    mutableListOf: 리스트로 정의한다.
    2개의 Achievement Entity를 가진 리스트를 achievements필드에 초기화 함 => 엔티티를 만들어 주입받는 jpa repository들을 이용해 DB에 데이터를 넣어주는 작업

  • achievementRepository.saveAll(achievements-리스트): 레파지토리에 리스트로 insert한다.
    achievementRepository interface에 아무것도 없는데 메소드에 사용이 가능하다
    (Spring Data JPA에서 만들어주는 기능이다.
    AchievementRepository가 상속하는 JpaRepository에 다양한 메소드들이 정의되어 있다.
    스프링이 실행되면서, 만든 인터페이스와 상속하는 인터페이스들 안에 실제 동작하는 기능을 가진 코드를 가지고 있는 repository 클래스를 만들어준다. 그 클래스들이 빈으로 등록된다.
    때문에 기본적인 기능들은 - list로 insert하는 것 등등 - 개발자가 하나하나 쿼리를 짤 필요 없이 간단하게 사용 가능하다. ) => 헷갈림

  •  val introductions = mutableListOf<Introduction>(3개의 엔티티): ..복붙

  • experienceRepository.saveAll(mutableListOf(experience1, experience2))
    experience를 리스트로 만들어 saveAll() 함수로 넘김. saveAll()을 통해 영속성 컨텍스트에 들어감. 현재 트랜잭션이 종료 될 때 영속성 컨텍스트에 있는 내용들이 insert로 DB에 들어감. 그때, Experience가 가진 detail들이 같이 insert로 들어간다.

experience1.addDetails(
    mutableListOf(
        ExperienceDetail(content = "GPA 4.3/4.5", isActive = true),
        ExperienceDetail(content = "소프트웨어 연구 학회 활동", isActive = true)
    )
)

experience2.addDetails(
    mutableListOf(
        ExperienceDetail(content = "유기묘 위치 공유 서비스 개발", isActive = true),
        ExperienceDetail(content = "신입 교육 프로그램 우수상 수상", isActive = true)
    )
)
  • 이 때 만약, Experience.kt 안의 var details@OneToMany(targetEntity = ExperienceDetail::class, fetch = FetchType.LAZY, cascade = [CascadeType.ALL])에서 CascadeTypeALL로 안하면, DB에 Experience는 입력되지만 detail은 insert 쿼리에서 제외된다.

  • 만든 엔티티(Skill)를 변수에 다 할당해준다.
    나중에 Project에서 projectSkill과 연결해서 재사용할 예정임.

    val java = Skill(name = "Java", type = SkillType.LANGUAGE.name, isActive = true)
    val kotlin = Skill(name = "Kotlin", type = SkillType.LANGUAGE.name, isActive = true)
    val python = Skill(name = "Python", type = SkillType.LANGUAGE.name, isActive = true)
    val spring = Skill(name = "Spring", type = SkillType.FRAMEWORK.name, isActive = true)
    val django = Skill(name = "Django", type = SkillType.FRAMEWORK.name, isActive = true)
    val mysql = Skill(name = "MySQL", type = SkillType.DATABASE.name, isActive = true)
    val redis = Skill(name = "Redis", type = SkillType.DATABASE.name, isActive = true)
    val kafka = Skill(name = "Kafka", type = SkillType.TOOL.name, isActive = true)
    skillRepository.saveAll(mutableListOf(java, kotlin, python, spring, django, mysql, redis, kafka))
    • 변수로 초기화를 하지 않고

    Skill(name = "Java", type = SkillType.LANGUAGE.name, isActive = true)

    생성자만 가지고 skillRepository를 이용해 한번에 DB에 넣으면 나중에 project에서 가져오기 복잡해진다. 그래서 미리 정의해준다.

  • Project는 experience와 비슷

    • addDetails()와 같이, skills.addAll() 함수로 묶어서 project1에 넣어줄 수 있다.

// 방법1
project1.addDetails(
    mutableListOf(
        ProjectDetail(content = "구글 맵스를 활용한 유기묘 발견 지역 정보 제공 API 개발", url = null, isActive = true),
        ProjectDetail(content = "Redis 적용하여 인기 게시글의 조회 속도 1.5초 → 0.5초로 개선", url = null, isActive = true)
    )
)
// 방법2 => 다양한 방법이 있다
project1.skills.addAll(
    mutableListOf(
        ProjectSkill(project = project1, skill = java),
        ProjectSkill(project = project1, skill = spring),
        ProjectSkill(project = project1, skill = mysql),
        ProjectSkill(project = project1, skill = redis)
    )
)

val: 불변(Immutable) 변수로, 값의 읽기만 허용되는 변수. 값(Value)의 약자이다.
변수를 선언할 때 지정한 값에서 더이상 변경하지 않는 경우

var: 가변(Mutable) 변수로, 값의 읽기와 쓰기가 모두 허용되는 변수. 변수(Variable)의 약자이다.
변수의 값을 바꿔야 하는 경우

출처: https://kotlinworld.com/173

 

리포지토리 개발

JAP엔티티를 미리 정의해 두고 인터페이스만 만들면 Spring이 실행되면서, 리포지토리 인터페이스를 기반으로 리포지토리 클래스들을 만들어서 Spring Bean으로 등록한다.

@Entity
class Experience(...

interface AchievementRepository : JpaRepository<Achievement, Long> {...

서비스 Bean에서 리포지토리 빈들을 주입받아서 바로 사용 가능하다. 이때 사용하는 기능들은 Insert, Update, ID로 조회하기, ID로 삭제하기 등이 있다. 특정 컬럼 조회하기 등은 기본 메소드에 없다.
인터페이스에 미리 정해진 규칙대로 메소드 이름을 정의해주면, 메소드 이름을 기반으로 쿼리를 작성해준다. => A부터 Z까지의 컬럼이 있을 때, 개발자가 A, B를 조회하고 싶다면 'Find by A and B' 이런 식으로 메소드 이름을 정의하고 파라미터로 A와 B를 넣어 주도록 인터페이스에 메소드를 정의하면 된다.

// select * from achievement where is_active = :isActive
fun findAllByIsActive(isActive: Boolean): List<Achievement>

SkillRepository.kt 에는 메소드를 하나 더 만든다.

// select * from skill where lower(name) = lower(:name) and skill_type = :type
fun findByNameIgnoreCaseAndType(name: String, type: SkillType): Optional<Skill>
  • Optional<Skill> 로 Skill 단건을 조회하게 함.
    case를 무시하라고 했기에, 전부 다 대문자나 소문자로 변경(컬럼도) => 뭔가 추가적인 지식이 있는 듯

  • 위 내용을 순수한 쿼리로 작성하면, 구체적인 DB 시스템에 종속된다. => 예를 들어, lower 함수 같은 경우. mySQL은 lower이라고 써도 오라클이나 다른 DBMS에서는 같은 기능을 다른 함수로 쓸 수 있기 때문.
    => Spring Data JPA에서 이런 부분을 개발자가 신경 안쓰게 하기 위해 'IgnoreCase'로 각각 DBMS에 맞게 변경해줌

 

리포지토리 테스트 코드 작성

테스트 코드는 매우 정말 중요하다.
=> 강의용 프로젝트같이 규모가 작은 경우에는 덜 중요할 수 있다.

IntelliJ는 특정 클래스의 테스트 클래스를 쉽게 만들어주는 기능을 제공한다.

DataInitializerTest.kt

[테스트할 클래스 -> 마우스 오른쪽 -> Generate -> test]
도메인 등 원래 클래스가 있던 것과 같은 경로test패키지 안에 test 클래스가 생성된다.
=> DataInitializerTest.kt 삭제 (테스트할 대상이 Spring Data JPA Repository Interface 이기 때문)
인터페이스여서 테스트 클래스를 만들 수 없고 같은 규칙으로 직접 만듦

test>kotlin>com>bohui>portfolio>domain 안에 패키지 '리포지토리'를 만든다.

테스트 코드 작성은 많은 작업이 필요해서 오래 걸린다. => 때문에 찐 테스트 코드를 작성하지 않고, 작성하는 방식을 보여줄 예정

Experience와 Project Repository 에 대해서만 테스트 클래스 생성

ExperienceRepositoryTest.kt

@DataJpaTest
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class ExperienceRepositoryTest(
    @Autowired val experienceRepository: ExperienceRepository // 테스트할 대상을 주입받음
) 
  • @DataJpaTest:
    jpa 관련 테스트 할 때 사용하는 어노테이션.
    이 테스트 코드가 실행될 때, jpa 사용이 가능한 만큼 스프링 빈을 만들어 준다.
    Transactional이라는 어노테이션을 가지고 있다.

    • @Transactional: 테스트 메소드 하나를 하나의 트랜잭션으로 보고, 메소드가 종료될 때 그 트랜잭션에서 발생한 모든 작업을 롤백함.

    • 테스트 코드에서 중요 원칙 중 하나는 독립적으로 항상 같은 결과를 내야한다는 것. => 인메모리 DB를 쓰면 상관없지만 안쓰면 롤백해야함. 안하면 테스트 코드 돌릴 때마다 테스트용 데이터가 계속 쌓여 다음 테스트에 영향을 줄 수 있다.
      => 때문에 Transactional 어노테이션을 달아서 자동 롤백이 되게 함.

  • @TestInstance(TestInstance.Lifecycle.PER_CLASS):

    • TestInstance.Lifecycle.PER_CLASS:
      TestInstance의 라이프 사이클이 클래스 단위가 됨.
      원래 기본값으로, 이 테스트 코드를 돌리는 로직이 NewExperienceRepositoryTest 해서 메소드 한개 돌리고 또 NewExperienceRepositoryTest 해서 두번째 메소드 돌리는 식으로 수행이 됨. (같은 클래스에 있는 메소드들 이지만, 메소드 마다 인스턴스 생성)
      라이프 사이클을 클래스 레벨로 해주면, 인스턴스를 한번 만들어서 그 안에 있는 여러 메소드들을 수행한다.
      그래도, 메소드마다 TestInstance를 만들어 테스트를 돌리면, 메소드간 의존적이지 않다는(독립적) 장점이 있다.
      내부적 메소드 간에 의도적으로 의존적이게 만들고 싶을 때, 클래스를 만들어서 메소드를 1번, 2번, 3번 다 돌리는게 낫다...(이해가 더 필요)

      • @BeforeAll: 테스트 데이터를 초기화하는 메소드. 다른 메소드가 돌기 전에 제일 처음에 딱 한번 돌아야 함. 때문에, TestInstance의 라이프 사이클을 클래스 단위로 해줘야 함.
        'DataInitializer.kt'는 개발 편의를 위해 임의로 만든 것으로, 이렇게 데이터를 초기화하는 방식은 좋지는 않다(왜?). 때문에 독립적으로 사용하기 위해 BeforeAll로 초기화할 예정. 그리고 @DataJpaTest를 사용하면 그 스프링 데이터 jpa를 테스트하기에 필요한 기능들만 초기화 가능하다. 그래서 테스트 돌릴 때, DataInitializer의 내용들은 빈으로 등록 안됨.

    • TestInstance.Lifecycle.PER_METHOD:
      라이프사이클을 메소드로 할때, BeforeAll이 돌아 초기화를 해줬지만 다음에 돌아가는 테스트 메소드들은 BeforeAll의 영향을 받을 수 없다.

  • @Autowired val experienceRepository: ExperienceRepository: 생성자로 테스트할 대상을 주입받음

  • private fun createExperience(n: Int): Experience:
    테스트 데이터 초기화를 할 때 더미 엔티티를 만들어주는 기능, 받은 'n'의 개수만큼 이 'Experience' 안에 디테일을 넣어준다.

    • val experience: 비어있는 더미 객체(entity) 생성

    • 기능단위로로 메소드를 분리해 주는 것이 구조적으로 소스 코드를 파악하기 더 용이하다.

  • @BeforeAll: 테스트 데이터 초기화

    • Assertions (org,assertj.core.api)

    • Assertions.assertThat(beforeInitialize).hasSize(0): 테스트를 검증하는 메소드 (의도한대로 동작을 했는지)
      'beforeInitialize'에서 받은 데이터의 사이즈를 체크
      '0' 이면 테스트를 통과, 그 외에는 테스트 실패

// 테스트 데이터 초기화
@BeforeAll
fun beforeAll() {
    println("----- 데이터 초기화 이전 조회 시작 -----")
    val beforeInitialize = experienceRepository.findAll()
    assertThat(beforeInitialize).hasSize(0) // 테스트를 검증하는 메소드
    println("----- 데이터 초기화 이전 조회 종료 -----")
    println("----- 테스트 데이터 초기화 시작 -----")
    val experiences = mutableListOf<Experience>()
    for (i in 1..DATA_SIZE) {
        val experience = createExperience(i)
        experiences.add(experience)
    }
    experienceRepository.saveAll(experiences)
    println("----- 테스트 데이터 초기화 종료 -----")
}
  • @Test: 메소드를 테스트 메소드로 인식되게 함

@Test
fun testFindAll() {
    println("----- findAll 테스트 시작 -----")
    val experiences = experienceRepository.findAll()
    assertThat(experiences).hasSize(DATA_SIZE)
    println("experiences.size: ${experiences.size}")
    for (experience in experiences) {
        assertThat(experience.details).hasSize(experience.title.toInt())
        println("experience.details.size: ${experience.details.size}")
    }
    println("----- findAll 테스트 종료 -----")

 

리포지토리 성능 개선

JPQL의 fact join을 활용해 jpa에서 발생하는 n+문제를 해결하고, ProjectRepositoryExperienceRepository의 성능을 개선.

ExperienceRepositoryTest.ktfun testFindAllByIsActive() 실행

  • 11개의 쿼리가 실행됨 => jpa에서의 n+1 문제
    부모데이터 1번 조회 (결과: 10개) -> 각 자식데이터 조회 10번

  •  JPA에서 proxy를 쓰는데, proxy는 가짜 객체이다.
    디테일을 바로 가져오는게 아닌 한번 랩핑된 가짜객체를 가지고 있고, 그 가짜 객체 안에 var details가 호출될 때 Query가 나가는 로직이 있음.
    => 디테일 호출 -> 가짜 객체 호출 -> 가짜 객체에서 진짜 데이터를 안가지고 있으니, DB에서 쿼리를 가져옴

  • 근데 너무 비효율적임 => FetchJoin 활용

ExperienceRepository.ktfun findAllByIsActive위에 @Query("select e from Experience e left join fetch e.details where e.isActive = :isActive") 달아줌
- 'e' alias 별칭

  • jpql: 자바의 객체지향적인 쿼리. sql과 비슷한데, 좀 더 객체의 관점에서 작성할 수 있는 sql. JPA에서 JPQL을 가지고 실제로 DBMS에 맞는 쿼리로 바꿔서 DB로 쿼리를 보냄 (ex. @Query)

  • 쿼리가 한개만 나갔다.

ProjectRepository.kt
projectSkill, projectDetail과 관계를 맺고 있다.

패치조인의 단점, 한계점이 위와 같이 여러 개의 엔티티와 관계를 맺고 있을 때, 이것들을 한꺼번에 조회할 수 없다.
=> 네이티브 쿼리로 풀거나, 쿼리 DSL or something
=> yml의 default_batch_fetch_size: 10 을 통해 어느정도의 성능 문제를 해결 => n+1의 완전히 해결하는 것이 아닌 fetchSize의 값에 따라, m의 팻치사이즈가 n번 나가는 쿼리를 n/m으로 줄여준다.

댓글을 작성해보세요.

채널톡 아이콘