로깅(Logging)이란 시스템의 작동 정보인 로그(log)를 기록하는 행위를 말한다. 시스템이 작동할 때 시스템의 작동 상태의 기록과 보존, 이용자의 습성 조사 및 시스템 동작의 분석 등을 하기 위해 작동 중의 각종 정보를 기록해둘 필요가 있는데, 이 기록을 만드는 것을 로깅이라 한다. 즉 로그 시스템의 사용에 관계된 일련의 사건을 시간의 경과에 따라 기록하는 것이다.
log가 아닌 단순한 System.out.println()은 print() 메소드에 synchronized 키워드가 있어서 멀티쓰레드 환경에서 다른 쓰레드는 Block이 걸리게 된다. 그러면 다른 쓰레드는 일을 할 수 없기 때문에 성능저하가 발생할 수 있다.
System.out.println() 메소드를 방치하면 I/O 요청이 발생할 때마다 쓸데없는 리소스를 잡아먹게 되는 것이다.
Java에는 Log4j, Log4j2, Logback등의 로깅 프레임워크가 존재한다.
Slf4j
Slf4j(Simple Logging Facaed for Java)는 로깅 프레임워크가 아닌 logger의 추상체로서 다른 로깅 프레임워크가 접근할 수 있도록 도와주는 추상화 계층이다. Log4j나 Logback 같은 로깅 프레임워크의 인터페이스 역할을 한다.
Slf4j는 추상 로깅 프레임워크이기 때문에 단독적으로 사용하지 않는다.
코드상에서는 Slf4j의 interface코드를 사용하고 실제 로깅을 하는 구현체는 추가로 참조한 라이브러리에서 구현된다.
Java의 logging 모듈들은 Slf4j의 브릿지를 이미 제공한다. Slf4j와 Logback을 연결하기 위해서 추가로 무언가를 구현 할필요가 없다. logback을 쓰고 싶으면 slf4j-api를 log4j2를 쓰고 싶다면 log4j-slf4j-impl과 log4j-api를 추가하면 된다.
Log4j
Log4j는 Apache의 오래된 로깅 프레임워크로써 2015년 개발 중단이 되었다.
속도에 최적화가 되어있다.
Multi-Thread 환경에서도 안전하다. (Thread-safe하다.)
로그의 출력 형식을 쉽게 변경할 수 있다.
출력을 파일, 콘솔, 원격서버, 등등 다양한 방식으로 내보낼 수 있고, 심지어 email로도 보낼 수 있다.
MapStruct 라이브러리를 이용해서 Dto와 Entity간 변환하는 테스트가 성공했기 때문에 큰 이상을 느끼지 못하고 다음작업을 하고 있었는데 갑자기 잘되던 회원가입 로직이 동작하지 않았다.
에러내용:
detached entity passed to persist: koo.project.matcheasy.domain.member.Member; nested exception isorg.hibernate.PersistentObjectException: detached entity passed to persist:
"준영속 상태인 엔티티가 영속되기위해 전달되었다"
아직 복잡한 단계의 로직을 짜지 않았고 엔티티 준영속 상태가 될 이유가 없다고 생각했었다.. 뭔가 트랜잭션 설정에 문제가 있다고 판단했다.
의심1.
MemberRepository는 클래스 레벨에서 @Transactional 애노테이션을 readOnly = true 옵션으로 지정하여 안의 메소드를 읽기전용으로 지정을 해주었다. save는 직접 DB에 접근하기 때문에 readOnly옵션을 풀어준 @Transactional 애노테이션만 붙여준 상황이었다.
여기서 발견한 잘못된 점은 Service계층에서는 @Transactional 애노테이션을 붙여주지 않았다는 것이었다. 한개의 Request가 있고 난 후, 같은 트랜잭션 단위로 동작하기 위해서는 Service에서도 @Transactional을 붙여주어야 할 것 같았다.
@Test annotation과 함께 설정된 @Transactional은 항상 rollback 된다고 한다. 테스트 환경에서 save로직이 실행되지 않았기에 테스트클래스에 @Rollback(value = false) 애노테이션을 붙여주어 롤백되지 않고 직접 데이터베이스에 저장되는 것을 확인하고자 하였다.
MapStruct의 Mapper 파일이다. toEntity메서드는 Dto를 Entity로 바꾸어주는 메서드이다. @Mapping을 이용하여 매핑속성을 지정할 수 있다.
target은 Entity의 field를 지정하고, constant는 그 field를 상수 0L로 지정하겠다는 의미이다.
Member엔티티는 DB와 매핑되는 필드를 가지고 있기 때문에 id값이 있지만, Dto는 id필드가 없다. 그러므로 toEntity를 할때 id필드의 값이 없어서 상수로 지정해준 것이다. (id값이 없어서 Error가 날 수도 있을 것 같아서 테스트를 진행할 때 constant로 박아놓고 잘 돌아가는군. 이라고 생각했다......)
그런데, Member엔티티의 id값은 @GeneratedValue의 default전략인 AUTO로 지정했기 때문에 기본키를 전략에 맞게 자동으로 증가시킨다. (H2데이터베이스로 로컬테스트를 하고 있기 때문에, 기본전략은 SEQUENCE전략이다)
처음 시퀀스값이 0인 상태에서 DB에 접근할 때마다 sequence값을 증가시켜서 id값을 지정해야하는데 0L 상수로 고정이 되어 있었기 때문에 에러가 발생한 것이었다.
- 예) String name, int age - 생명주기를 엔티티에 의존한다. 회원을 삭제하면 이름, 나이 필드도 함께 삭제 된다. - 값 타입은 공유하면 안된다. 회원 이름 변경시 다른 회원의 이름도 함께 변경되면 안된다.
2. 임베디드 타입(embedded type, 복합 값 타입)
- 새로운 값 타입을 직접 정의할 수 있다. - JPA는 임베디드 타입(embedded type)이라고 한다. - 주로 기본 값 타입을 모아서 만들어서 복합 값 타입이라고도 한다. - int, String과 같은 값타입이다. 추적도 되지 않고 변경하면 다른값으로 대체된다. - @Embeddable : 값 타입을 정의하는 곳에 표시 - @Embedded : 값 타입을 사용하는 곳에 표시 - 기본 생성자가 필수로 필요하다.
Member 클래스에 Period, Address 라는 이름의 임베디드 타입을 추가해보자.
Address와 Peroid 클래스를 만들 때 @Embeddable 애노테이션을 붙여준다. (Getter/Setter는 생략)
Member 클래스에서 @Embedded 애노테이션을 사용하여 값 타입을 사용하면 된다. @Embedded 애노테이션은 생략이 가능하지만 명시해주는 것이 좋다.
임베디드 타입은 엔티티의 값일 뿐이다. 임베디드 타입을 사용하기 전과 후의 매핑하는 테이블은 같다. 하지만 Member 엔티티의 확장성이 생기면서 객체지향적인 코드가 된다. 예를 들어, Period클래스에 현재시간과 비교하여 기간을 산출하거나 총 기간을 계산하는 등 기간과 관련된 의미있는 메서드를 만들어서 활용할 수도 있게 된다.
임베디드 타입을 사용하면 객체와 테이블을 아주 세밀하게(find-grained) 매핑하는 것이 가능해진다. 잘 설계한 ORM 애플리케이션은 매핑한 테이블의 수보다 클래스의 수가 더 많다.
<장점> - 재사용이 가능하다. - 높은 응집도를 가진다. - 해당 값 타입만 사용하는 의미 있는 메소드를 만들 수 있다. - 임베디드 타입을 포함한 모든 값 타입은 값 타입을 소유한 엔티티에 생명주기를 의존한다.
- @AttributeOverride : 속성 재정의 한 엔티티에서 같은 값 타입을 사용하면? 컬럼명이 중복된다. @AttributeOverride를 사용해서 컬러명 속성을 재정의하면 된다.
3. 컬렉션 값 타입(collection value type)
값 타입과 불변 객체
임베디드 타입 같은 값 타입을 여러 엔티티에서 공유하면 위험하다. 부작용(side effect)이 발생할 수 있다.
의도는 member1의 city를 고치고 싶었겠지만, 임베디드 타입은 값 타입이기 때문에 member1의 city와 member2의 city가 모두 "daejeon"으로 바뀌게 된다.
값 타입의 실제 인스터스인 값을 공유하는 것은 위험하다. 대신 값(인스턴스)를 복사해서 사용해야 한다.
이렇게 사용하거나 혹은 임베디드 타입의 장점을 이용해서 Address클래스에 인스턴스를 복사하는 메서드를 만들어서 사용할 수도 있겠다.
이렇게 항상 값을 복사해서 사용하면 공유 참조로 인해 발생하는 부작용을 피할 수 있다. 그런데 문제는 임베디드 타입처럼 직접 정의한 값 타입은 자바의 기본 타입이 아니라 객체 타입이다. 객체 타입은 참조 값을 직접 대입하는 것을 막을 방법이 없다. 객체의 공유 참조를 피할 수 없다. (타입만 맞으면 다 대입이 된다)
해결방법은? 객체 타입을 수정할 수 없게 만들면 부작용을 원천 차단할 수 있다. 값 타입은 불변객체(immutable object)로 설계해야 한다. 생성자로만 값을 설정하고 수정자(setter)를 만들지 않으면 된다. 참고로 Integer, String 은 자바가 제공하는 대표적인 불변객체이다.
값 타입의 비교
동일성(identity)비교 : 인스턴스의 참조 값을 비교, == 사용 동등성(equivalence)비교 : 인스턴스의 값을 비교, equals()사용
값 타입은 a.equals(b)를 사용해서 동등성을 비교해야 한다. 값 타입의 equals()메소드를 적절하게 재정의 해야한다.(주로 모든 필드 사용)
값 타입 컬렉션
값 타입을 하나 이상 저장할 때 사용한다. @ElementCollection, @CollectionTable 사용해서 매핑하면 된다. 데이터베이스는 컬렉션을 같은 테이블에 저장할 수 없다. 컬렉션을 저장하기 위한 별도의 테이블이 필요하다.
이와 같이 @ElementCollection과 @CollectionTable 애노테이션을 써서 매핑해주면 된다. @CollectionTable의 속성으로 joinColumns = @JoinColumn(name="MEMBER_ID" 를 지정해줌으로써 MEMBER_ID를 FK로 가지는 컬렉션 테이블을 생성해주게 된다.
값 타입의 저장예제
컬렉션 값타입은 다른 테이블임에도 불구하고 라이프 사이클이 같이 돌아 갔다. 왜냐면 이것은 값타입 이기 때문이다. 그렇기 때문에 별도로 persist를 하거나 update를 하거나 그럴 필요가 없다. 마치 값 타입 컬렉션은 영속성 전이(cascade = all)와 고아 객체 제거기능(orphanRemoval = true) 속성을 모두 써준것과 같이 동작한다.
값 타입의 조회예제
Member를 조회하면 Member만 Select한다. 그 말은 즉슨, 값타입 컬렉션도 지연로딩 전략을 default로 사용한다는 의미이다. 실제로 사용하는 시점이 와야 그제서야 컬렉션 값 타입의 데이터를 불러와서 가져올 것이다. (참고로 Embedded타입은 Member에 소속된 값 타입이기 때문에 조회 시 같이 불러와진다.)
값 타입의 수정예제
컬렉션 값 타입의 예를 보기전에 임베디드 값 타입의 수정을 잠깐 살펴보자.
컬렉션 값 타입도 결국 값 타입 이기 때문에 setter를 이용해서 수정하면 안된다! side effect 발생 인스턴스 자체를 갈아끼워야 한다.
이렇게!
그러면 값 타입 컬렉션의 수정은 어떻게 하면 될까?
값 타입 컬렉션일 경우, 방법이 따로 없다. remove()를 이용해서 해당 컬렉션의 데이터를 삭제 한 후, 다시 새로운 데이터를 add해서 넣어주어야 한다.
이것만 봐도 효율이 떨어지는 것 같기는 한데 더 큰 문제는 한개가 더 있다.
분명 한개의 데이터만 삭제하고 추가로 한개의 데이터만을 add 했는데 SQL은 delete한개와 insert 2개가 나간다. 값 타입 컬렉션에 변경사항이 발생하면 주인 엔티티와 연관된 모든 데이터를 삭제하고, 값 타입 컬렉션에 있는 현재 값을 모두 다시 저장한다.
값 타입 컬렉션의 제약사항
- 값 타입은 엔티티와 다르게 식별자 개념이 없다. - 값은 변경하면 추적이 어렵다. - 값 타입 컬렉션에 변경사항이 발생하면 주인 엔티티와 연관된 모든 데이터를 삭제하고, 값 타입 컬렉션에 있는 현재 값을 모두 다시 저장한다. - 값 타입 컬렉션을 매핑하는 테이블은 모든 컬럼을 묶어서 기본 키를 구성해야 한다. (null 입력x, 중복저장x)
값 타입 컬렉션 대안
- 실무에서는 상황에 따라 값 타입 컬렉션 대신에 일대다 관계를 고려해야 한다.(값 타입에서 엔티티로 승격) - 일대다 관계를 위한 엔티티를 만들고, 여기에 값 타입을 사용하는 방법으로 해야한다. - 영속성 전이(Cascade) + 고아 객체 제거를 사용해서 값 타입 컬렉션처럼 사용한다.
이처럼 AddressEntity라는 엔티티를 한개 만들어서 Address값 타입을 한번 래핑해주는 것이다.
List<Address>가 아닌 방금 만든 List<AddressEntity>를 사용하고 일대다 매핑을 해준다! 그리고 '영속성 전이(Cascade) + 고아 객체 제거'를 사용하여 값 타입 컬렉션처럼 이용한다.
- 값 타입 걸렉션은 만약 멀티 체크박스에서 내가 좋아하는 메뉴 여러개 선택가능하게 하는 기능이 있다. 그렇게 값이 단순하고 추적할 필요도 없고, 값이 바뀌어도 update할 필요가 없을 때 사용하는 것이다! 예를 들어 주소 이력 과 같은 정보는 무조건 엔티티로 사용해야 한다.
- 값 타입은 정말 값 타입이라고 판단 될때만 사용해야 한다. 엔티티와 값 타입을 혼동해서 엔티티를 값 타입으로 만들면 안된다. 식별자가 필요하고, 지속해서 값을 추적 및 변경해야 한다면 그것은 값 타입이 아니라 엔티티로 만들어야 한다.
정리
엔티티 타입의 특징
- 식별자가 있음 - 생명 주기가 관리 됨 - 공유를 할 수 있음
값 타입의 특징
- 식별자가 없음 - 생명주기를 관리하지 못하여 엔티티에 의존함 - 공유하지 않는 것이 안전 - 만약 공유해야 한다면 불변객체로 만드는 것이 안전
본 정리 내용은김영한님의 "자바 ORM 표준 JPA 프로그래밍 - 기본편"을 듣고 정리한 내용이며, 중요한 내용이 판단된 부분은 강의자료의 사진자료를 사용하였습니다.
JPA의 프록시
em.find() : 데이터베이스를 통해서 실제 엔티티 객체를 조회한다.
em.getReference() : 데이터베이스 조회를 미루는 가짜(프록시) 엔티티 객체를 조회한다. getReference()를 호출하는 시점에는 데이터베이스에 select 쿼리를 하지 않는다. 그런데 getReference()가 실제 사용되는 시점에 쿼리가 나간다.
프록시는 실제 클래스를 상속 받아서 만들어진다. 실제 클래스와 겉 모양이 같다. 사용하는 입장에서는 진짜 객체인지 프록시 객체인지 구분하지 않고 사용하면 된다. (이론상으로는) 프록시 객체는 실제 객체의 참조(target)를 보관한다. 프록시 객체를 호출하면 프록시 객체는 실제 객체의 메소드를 호출한다.
실제 사용되는 시점(사진에서는 1.getName())에서 프록시 객체는 getName()의 값을 아직 알지 못한다. 그래서 영속성 컨텍스트에 초기화를 요청한다. 그러면 영속성 컨텍스트는 DB에 들러서 실제 Entity를 생성해준다. 그리고 참조값을 가지고 있던 target에다가 실제 생성한 Entity를 연결해준다.
프록시의 특징
프록시 객체는 처음 사용할 때 한번만 초기화한다.
프록시 객체를 초기화 할 때, 프록시 객체가 실제 엔티티로 바뀌는 것은 아니다! 초기화되면 프록시 객체를 통해서 실제 엔티티에 접근이 가능한것이다.
프록시 객체는 원본 엔티티를 상속받는다. 따라서 타입 체크시 주의해야한다. (== 비교 실패, 대신 instance of 사용) (아래 사진 예시참고)
위와 같이 하면 첫번째 println은 "findMember1==findMember2 : false" 가 출력되고, 두번째와 세번째 println은 각각 true가 출력된다.
영속성 컨텍스트에 찾는 엔티티가 이미 있으면 em.getReference()를 호출해도 실제 엔티티를 반환한다. (아래 예제)
그 반대도 마찬가지이다. 처음에 em.getReference()로 조회하면 em.find()해도 프록시 객체를 반환한다. (아래 예제)
왜 이런 결과를 얻는 것일까?
1. 1차 캐시에 있기 때문에 2. 마치 컬렉션에 있는 값을 비교하는 것처럼 한개의 트랜젝션 안에서 같은 pk값을 갖는 엔티티는 같은 엔티티임을 보장해준다. 즉, ==비교를 true로 만들기 위해서 영속성 컨텍스트 안에 있으면 프록시가 아니라 실제 엔티티를 반환하게 된다. (이 개념은 매우 중요하다.)
영속성 컨텍스트의 도움을 받을 수 없는 준영속 상태일 때, 프록시를 초기화하면 문제가 발생한다. (하이버네이트는 org.hibernate.LazyInitializationException예외를 터뜨린다.)
하이버네이트 5.4.0.Final 버전까지는 단순히 세션(엔티티매니저)이 끝나면 예외가 터졌었는데, 5.4.1.Final 이상버전부터는 트랜젝션이 유지가 되면 Lazy로딩을 사용할 수 있도록 최적화 되었다. 버전이 5.4.1.Final 이상이라면 위의 예제와 같은 경우, 트랜잭션을 종료하지 않은 상태에서 세션(엔티티메니저)를 닫았기 때문에 예외가 터지지 않는다.
프록시 확인을 도와주는 유틸리티 메서드
프록시 인스턴스의 초기화 여부 확인 PersistenceUnitUtil.isLoaded(Object entity)
프록시 클래스 확인 방법 entity.getClass().getName()출력
프록시 강제 초기화 org.hibernate.Hibernate.intialize(entity); -> 참고 : 이건 하이버네이트가 지원하는 초기화 방법이고, JPA 표준에는 강제 초기화가 없음. 위에 예제들에서 했던 것처럼 member.getUserName(); 처럼 강제 호출 해야함.
즉시 로딩과 지연 로딩
Member와 Team이 다대일(@ManyToOne)로 연관관계 매핑이 되어있다고 가정해보자. 우리가 만든 프로그램에서 Member와 Team정보가 모두 필요한 경우보다 Member만 조회할 경우가 더 많다고 한다면 Member 엔티티를 조회할 때 항상 Team을 Join해서 조회해야할까? 단순히 Member정보만 사용하는 비즈니스 로직에서는 매우 손해일 것이다.
이럴때 FetchType을 LAZY로 하면 Member를 로딩할 때 Team은 프록시객체를 이용해서 지연로딩을 한다. 그 후 실제 Team의 속성을 실제로 사용하는 시점(Team을 불러오는 시점이 아니라 사용하는 시점이라는 것을 유의)에 프록시 객체가 초기화 되면서 값을 가지고 온다.
쉽게 말하자면 지연로딩일 때 Member를 로딩하면 Team에 프록시 객체를 넣어둔 상태로 Join하지 않고 Member만 가지고 오는 Select쿼리가 나가고, 실제 사용한다고 하면 그 때서야 Team을 가져오기 위한 Select 쿼리가 나간다.
반대로 Member와 Team을 거의 항상 같이 사용한다면? FetchType을 EAGER로 하여 Member와 Team을 Join하는 즉시로딩을 할 수 있다. 이 때 team을 조회하면 프록시가 필요없기 때문에 실제 객체가 나온다.
코드 예제를 보자. 예제 코드에는 Member가 JpaMember, Team이 JpaTeam이다.
연관관계의 주인인 JpaMember클래스이다. 그 안에 JpaTeam jpateam이 존재하고 fetch를 LAZY로 해줌으로써 지연로딩설정을 해주었다.
지연로딩으로 설정한 상태로 첫번째 println에서 findMember.getJpateam().getClass()하여 JpaMember안의 JpaTeam을 가져오려고 하고 있다.
(=== 는 실제 이 부분에서 쿼리가 나가는지 구분하기 위한 선)
두번째 쿼리에서는 findMember.getJpateam().getName()하여 JpaTeam의 이름을 실제로 호출하고 있다. 실제 사용되는 시점이다.
첫번째는 프록시 객체가 나왔고,
두번째는 실제 사용되는 시점이기 때문에 Select쿼리가 나가고 JpaTeam의 이름을 가지고 왔다.
프록시와 즉시로딩 주의
가급적 지연로딩만 사용하자
즉시 로딩을 적용하면 예상하지 못한 SQL이 발생한다. join sql이 너무 많이 발생할 수도 있다.
즉시 로딩은 JPQL에서 N+1 문제를 일으킨다.
JPQL은 SQL과 1:1 매칭이 되기 때문에 예를 들어, "select m from Member m" 과 같은 JPQL을 작성하면 "select * from Member"와 같은 SQL이 나간다. 그런데 Member의 Team이 즉시로딩으로 되어 있으면 값을 불러올 때 모두 채워져 있어야하기 때문에 "select * from Team"와 같은 SQL이 한번 더 나간다. N+1의 1은 최초쿼리, N은 최초쿼리로 부터 파생된 쿼리들이다.
@ManyToOne, @OneToOne은 기본이 즉시 로딩이므로 LAZY로 설정을 해주어야 한다.( ~ToOne시리즈는 모두 LAZY로 설정해 주어야한다고 기억하자)
영속성 전이와 고아 객체
영속성 전이 : CASCADE
특정 엔티티를 영속 상태로 만들 때 연관된 엔티티도 함께 영속상태로 만들고 싶을 때 사용한다. 예를 들어, 부모 엔티티를 저장할 때 자식 엔티티도 함께 저장하는 것이다.
위와 같은 관계인 Parent와 Child가 있다.
아래 예제코드에는 Getter Setter가 생략되어 있는데 있어야 한다..
Parent클래스에 있는 addChild 메서드는 연관관계 편의 메서드이다.
연관관계의 주인인 Child에는 당연히 값이 들어가야 하고, 객체 지향적인 관점에서 Parent에도 값이 들어가야 하기 때문에 연관관계 편의 메서드를 만들어서 Child값을 집어넣을 때 양쪽에 값을 모두 넣을 수 있는 메서드를 만들어서 관리한다.
@OneToMany의 속성으로 cascade를 설정해주었다.
(아래 transaction.commit() 있다고 하자.)
테스트 코드에서 child1과 child2를 추가해주었고, em.persist(parent); 를 해주었다. casecade속성이 없었다면 parent만 영속성 컨텍스트에 영속되면서 insert쿼리가 한개 나가지만 casecade속성으로 인해서 연관된 엔티티도 함께 영속상태로 만들기 때문에 child2개도 모두 영속상태가 되어 insert쿼리가 3개 나간다.
-주의점 - 1. 부모 엔티티와 자식 엔티티의 라이프 사이클이 거의 유사하거나 동일할 때만 사용해야 한다. 2. 소유자가 하나일때만 사용해야 한다.(부모 엔티티만 자식엔티티를 소유할 때)
고아객체
참조가 제거된 엔티티는 다른 곳에서 참조하지 않는 고아객체로 보고 삭제하는 기능이다. 컬렉션에서 빠진 객체는 연관관계가 삭제된다. orphanRemoval = true 옵션으로 사용한다.
@OneToMany의 속성으로 orphanRemoval = true을 해주었다.
em.flush(); em.clear(); 해줌으로써 영속성 컨텍스트(정확히는 쓰기 지연 SQL저장소에 쌓여있던 쿼리)에 있는 쿼리를 모두 날리고 영속성 컨텍스트를 모두 비웠다.
그 후 em.find() 하여 DB에 접근하여 Parent 객체를 가져왔다. 그리고 첫번째 자식 엔티티를 컬렉션에서 삭제하였다.
그 결과 Delete 쿼리가 나간다.
- 주의점 -
1. 참조하는 곳이 하나일 때, 특정 엔티티가 개인 소유일 때만 사용해야 한다. 2. @OneToOne, @OneToMany만 사용가능하다. 3. 개념적으로 부모를 제거하면 자식은 고아가 된다. 따라서 고아 객체 제거 기능을 활성화 하면, 부모를 제거할 때 자식도 함께 제거된다. 이것은 CasecadeType.REMOVE 처럼 동작한다.
영속성 전이 + 고아객체, 생명주기
CasecadeType.ALL + orphanRemoval = true
스스로 생명주기를 관리하는 엔티티는 em.persist()로 영속화, em.remove()로 제거할 수 있다. 두 옵션을 모두 활성화하면 부모 엔티티를 통해서 자식의 생명주기를 관리할 수 있다.
인프런 모든 개발자를 위한 HTTP 웹 기본 지식 - 김영한 강좌를 보고 정리한 내용이며 내용중 사용된 사진들은 해당 강좌의 수업자료에서 발췌하였습니다.
IP(Internet Protocol)
클라이언트와 서버가 통신할 때 필요한 프로토콜이다. 클라이언트와 서버가 단순한 선 한개로 연결되어 있으면 좋겠지만 우리는 인터넷을 이용해서 통신을 한다. 클라이언트가 가진 IP가 100.0.0.1 이고 서버가 가진 IP가 200.0.1 이라고 가정한다면 클라이언트는 시작지점의 프로토콜과 목적지 프로토콜을 가지고 서버로 보낸다. 목적지로 가는 길에는 수많은 노드들이 있다. 단순하게 IP주소로만 통신을 한다면 노드를 거치면서 패킷이 유실될 수도 있고, 목적지의 서버가 꺼져있다면 도달하지 못하는 문제가 있을 수도 있다.
IP 프로토콜은 다음과 같은 한계점이 있다.
비연결성 - 패킷을 받을 대상이 없거나 서비스 불능 상태여도 패킷을 전송한다.
비신뢰성 - 중간에 패킷이 사라지거나 패킷이 순서대로 오지 않을 수도 있다.
프로그램 구분 - 같은 IP를 사용하는 서버에서 통신하는 애플리케이션이 두개 이상이면 문제가 생긴다.
TCP, UCP
예를 들어 채팅 프로그램으로 문자를 보내려고 한다. 프로그램이 'Hello World!' 라는 메세지를 보낸다면 메세지를 생성하고 SOCKET 라이브러리를 통해서 전송계층에 메세지를 넘긴다. 그러면 TCP는 TCP정보를 메세지 데이터에 씌운다. 그리고 IP계층으로 가서 IP에 대한 정보들을 씌워서 IP패킷을 생성한다. IP패킷은 IP와 관련된 정보가 있고 그 안에 TCP와 관련된 정보가 있고 그 안에 메세지에 대한 정보를 가지고 있는 것이다. 그리고 네트워크 인터페이스 계층에서 LAN카드를 통해서 나갈 때 Ethernet Frame이 포함이 되어서 나간다.
TCP/IP 패킷에는 출발지 port, 목적지 port, 전송제어, 순서, 검증 정보 등의 정보가 담겨있는 TCP세그먼트를 출발지 IP, 목적지 IP등의 정보가 담겨있는 IP패킷이 감싸고 있는 형태가 된다.
TCP는 3way handshake를 통해서 IP의 문제점이었던 비연결성과 비신뢰성, 프로그램 구분을 해결한다.
클라이언트가 서버에 데이터를 전송하기 전에 먼저 서버와 정확히 연결이 되어 있는지 파악한 후에 데이터를 전송하는 과정이다.
먼저 클라이언트가 서버에게 SYN을 보내면 서버는 SYN+ACK를 응답한다. 그러면 클라이언트는 연결이 되어있구나 라고 판단하여 ACK를 다시 보낸다. 이렇게 3번에 걸쳐서 connection을 체크하는 것이다. 그 후에 실제 데이터를 전송한다.
이런 3way handshake을 통해서 비연결성을 해결하고, 중간에 패킷이 유실되는 것을 방지하기 때문에 비신뢰성을 해결할 수 있다. 또 한 TCP로 데이터를 전송하면 출발지 port, 목적지 port, 순서와 같은 정보를 함께 전송하기 때문에 한번에 둘 이상의 연결을 했을 때 어디에 필요한 정보인지 알 수 있고(port의 역할) 데이터의 순서도 보장이 된다.
IP는 아파트라면 PORT는 동호수라고 이해하면 된다.
UDP는 3way handshake도 없고 데이터 전달 보증도 되지 않고 순서도 보장되지 않는다. 기능이 거의 없어서 IP와 거의 같다. 하지만 단순하고 빠르기 때문에 사용하기도 한다. 이미 거의 모든 인터넷이 TCP기반으로 되어있기 때문에 더욱 최적하를 하고 싶다하면 UDP를 애플리케이션 레벨에서 추가 작업을 하여 사용한다.
DNS(Domain Name System)
IP로 통신을 하면 IP주소를 기억하기가 어려울 뿐더러 IP가 신규 IP로 변경이 되면 접근하기가 상당히 어렵다. 그래서 우리는 IP를 이해하기 쉬운 도메인으로 바꾸어 사용한다. DNS서버에 도메인명을 등록해놓으면 클라이언트는 도메인명으로 요청을 한다. 그러면 DNS서버에서는 IP주소로 응답을 한다. 그 후에 그 IP주소로 접속을 하는 것이다.
URI(Uniform Resource Identifier)
URI는 자원을 식별하는 방법이다. URI는 로케이터(Locator), 이름(Name)또는 둘다 추가로 분류할 수 있다.
https://programmingrecoding.tistory.com/newpost/?type=post#post -> 우리가 알고 있는 이게 URL
https:programmingrecoding:post:post -> 이런형태가 URN , 그냥 자원식별 이름의 그 자체이다.
위치는 변할 수 있지만, 이름은 변하지 않는다. URN 이름만으로 실제 리소스를 찾을 수 있는 방법이 보편화되지 않았다. URI와 URL을 같은 의미로 사용하기도 한다.
본 정리 내용은"김영한님의 자바 ORM 표준 JPA 프로그래밍 - 기본편"을 듣고 정리한 내용이며, 중요한 내용이 판단된 부분은 강의자료의 사진자료를 사용하였습니다.
1. 상속관계 매핑
관계형 데이터베이스는 상속관계가 없다. 그나마 비슷한 모델은 슈퍼타입 서브파입 관계라는 논리 모델링 기법이 객체 상속과 유사하다. 상속관계 매핑은 객체의 상속과 구조와 DB의 슈퍼타입 서브타입 관계를 매핑하는 것이다.
주요 애노테이션 1. @Inheritance(strategy = InheritanceType.xxx)
JOIND : 조인전략 - 가장 정규화된 데이터베이스 모델.
장점 : - 가장 정규화된 데이터베이스 모델이다. - 외래키 참조 무결성 제약조건을 활용 가능하다. - 저장공간이 효율적이다.
단점 : - 조회시 조인을 많이 사용하여 성능이 저하된다. - 조회 쿼리가 복잡하다. - 데이터 저장 시 INSERT SQL을 2번 호출한다.
SINGLE_TABLE : 단일 테이블 전략 - 한개의 테이블에 모두 다 집어 넣고 DTYPE으로 구분한다.
장점 : - 조인이 필요 없으므로 일반적으로 조회 성능이 빠르다. - 조회 쿼리가 단순하다.
단점 : - 자식 엔티티가 매핑한 컬럼은 모두 null을 허용한다. - 단일 테이블에 모든 것을 저장하므로 테이블이 커질 수 있다. 상황에 따라서 조회 성능이 오히려 느려질 수도 있다.
TABLE_PER_CLASS : 구현 클래스마다 테이블 전략 - 슈퍼타입 테이블을 없애고 슈퍼타입에 속성들이 서브타입의 속성들로 중복되어서 들어간다.
장점 :
- 서브 타입을 명확하게 구분해서 처리할 때 효과적이다. - not null 제약조건을 사용가능하다.
단점 : - 여러 자식 테이블을 함께 조회할 때 성능이 느리다.(UNION SQL이 필요) - 자식 테이블을 통합해서 쿼리하기 어렵다. - 최대한 사용을 하지 말자.
@DiscriminatorColumn(name="DTYPE")
DTYPE이라는 컬럼을 추가하여 어떤 서브타입 테이블의 데이터가 들어왔는지 쉽게 파악이 가능하다.
@DiscriminatorValue("xxx")
서브타입 객체의 DTYPE 필드 value를 바꿀 수 있다.
2. @MappedSuperclass
공통 매핑 정보가 필요할 때 사용한다. 공통 속성만 부모클래스에 두고 상속하여 사용하고 싶을 때 사용한다. DB 입장에서는 똑같은데 객체 입장에서 속성만 상속받아서 사용하는 것이다.
테이블과 관계가 없고, 단순히 엔티티가 공통으로 사용하는 매핑 정보를 모으는 역할이며, 주로 등록일, 수정일, 등록자, 수정자 같은 전체 엔티티에서 공통으로 적용하는 정보를 모을 때 사용한다.
참고 : @Entity 클래스는 엔티티나 @MappedSuperClass로 지정한 클래스만 상속가능
특징
- 상속관계 매핑이 아니다. - 엔티티가 아니므로 테이블과 매핑이 되는것이 아니다. - 부모 클래스를 상속 받는 자식 클래스에 매핑 정보만을 제공하는 것이다. - 조회, 검색이 불가하다.(em.find()불가) - 직접 생성해서 사용할 일이 없으므로 추상 클래스를 권장한다.
위와 같이 @MappedSuperclass 애노테이션을 붙인 클래스를 만들고 extends를 이용하여 상속받아서 사용하면 된다. BaseEntity는 @MappedSuperclass이기 때문에 테이블이 생성되지는 않는다.
본 정리 내용은 "김영한님의 자바 ORM 표준 JPA 프로그래밍 - 기본편"을 듣고 정리한 내용이며, 중요한 내용이 판단된 부분은 강의자료의 사진자료를 사용하였습니다.
연관관계 매핑시 고려상항 3가지
1. 다중성
다대일 (@ManyToOne)
- DB입장에서 생각해보면 MEMBER가 N, TEAM이 1이다. 그러면 MEMBER에 외래키가 있어야 한다. - 외래 키가 있는 쪽이 연관관계의 주인이고, 양쪽을 서로 참조하도록 개발해야 한다.
일대다 (@OneToMany)
- 일대다 단방향은 일대다(1:N)에서 일(1)이 연관관계의 주인인 방식이다. - 테이블 일대다 관계는 항상 다(N) 쪽에 외래키가 있다. - 객체와 테이블의 차이 때문에 반대편 테이블의 외래키를 관리하는 특이한 구조이다.. - @JoinColumn을 꼭 사용해야 한다. 그렇지 않으면 조인 테이블 방식을 사용한다.(중간에 테이블을 하나 추가하는 방식)
- 엔티티가 관리하는 외래 키가 다른 테이블에 있다는게 최대 단점이다. - 연관관례 관리를 위해 추가로 UPDATE SQL을 실행한다. - 일대다 단방향 매핑보다는 다대일 양방향 매핑을 사용하자.
- 일대다 양방향 매핑도 할 수 있다. Member에 Team team을 만들어주고 @JoinColumn(insertable=false, updatable=false)를 해주어서 강제로 읽기 전용으로 매핑을 해주는 것이다. 이런 매핑은 공식적으로 존재하지는 않는다. 읽기 전용 필드를 사용해서 양방향처럼 사용하는 방법이다.
일대일 (@OneToOne)
- 일대일 관계는 그 반대도 일대일이다. - 주 테이블이나 대상 테이블 중에 외래키를 선택하는게 가능하다. - 외래 키에 데이터베이스 유니크(UNI) 제약조건 추가가 되어야 일대일 관계가 된다. - 단방향은 @ManyToOne관계의 단방향과 마찬가지로 @OneToOne 애노테이션을 붙여주고 @JoinColumn하여 외래키를 지정해주면 되고, 양방향은 역방향에 @OneToOne(mappedBy = "key")를 적용해주면 된다. - 일대일에서 대상 테이블에 외래 키 단방향 관계는 JPA에서 지원을 하지 않는다. 양방향 관계는 지원한다.
주 테이블에 외래키가 있는 경우 - 주 객체가 대상 객체의 참조를 가지는 것처럼 주 테이블에 외래키를 두고 대상 테이블을 찾는다. - 객체지향 개발자가 선호하는 방법이다. - JPA 매핑이 편리하다. - 장점 : 주 테이블만 조회해도 대상 테이블에 데이터가 있는지 확인 가능하다. - 단점 : 값이 없으면 외래 키에 null을 허용한다.
대상 테이블에 외래키가 있는 경우 - 전통적인 테이터베이스 개발자가 선호하는 방법이다. - 장점 : 주 테이블과 대상 테이블을 일대일에서 일대다 관계로 변경할 때 테이블 구조를 유지한다. - 단점 : 프록시 기능의 한계로 지연 로딩으로 설정해도 항상 즉시로딩 된다.
다대다 (@ManyToMany)
- 관계형 데이터베이스는 정규환된 테이블 2개로 다대다 관계로 표현할 수 없다. 그래서 연결 테이블을 추가해서 일대다, 다대일 관계로 풀어내야 한다. 하지만 객체는 컬렉션을 사용해서 객체 2개로 다대다 관계가 가능하다. - 연결 테이블이 단순이 연결만 하고 끝나지 않는다. - 주문시간, 수량 같은 데이터가 들어올 수 있다. - @ManyToMany -> @OneToMany, @ManyToOne으로 바꾼다. - 실무에서는 절대 쓰지말자
2. 단방향, 양방향
테이블
- 외래 키 하나로 양쪽 조인 가능 - 사실 방향이라는 개념이 없음
객체
- 참조용 필드가 있는 쪽으로만 참조 가능 - 한쪽만 참조하면 단방향 - 양쪽이 서로 참조하면 양방향
3. 연관관계의 주인
- 테이블은 외래 키 하나로 두 테이블이 연관관계를 맺음 - 객체 양방향 관계는 A->B, B->A처럼 참조가 2군데 - 객체 양방향 관계는 참조가 2군데 있음. 둘중 테이블의 외래키를 관리할 곳을 지정해야한 - 연관관계의 주인 : 외래 키를 관리하는 참조 - 주인의 반대편 : 외래키에 영향을 주지 않음, 단순 조회만 가능
본 정리 내용은 "김영한님의 자바 ORM 표준 JPA 프로그래밍 - 기본편" 을 듣고 정리한 내용이며, 설명에 필요한 사진 중 중요한 사진이라고 생각한 사진을 강의자료에서 가져왔음을 출처로서 밝힙니다.
단방향 연관관계와 양방향 연관관계를 예제 코드를 작성하면서 정리해보았다. 예제의 시나리오는 다음과 같다.
1. 회원과 팀이 있다.
2. 회원은 하나의 팀에만 소속될 수 있다.
3. 회원과 팀은 다대일 관계이다.
객체를 테이블에 맞추어 데이터 중심으로 모델링하면, 협력 관계를 만들 수 없다. 테이블은 외래키로 조인을 사용해서 연관 테이블을 찾는다. 하지만 객체는 참조를 사용해서 연관된 객체를 찾는다. 테이블과 객체 사이에는 이런 큰 간격이 있다. 이를 어떻게 해결 할 수 있을까?
단방향 연관 관계
테이블 연관관계를 보면 MEMBER에서 TEAM_ID를 외래키로 가짐으로써 멤버가 어떤 팀에 소속되어 있는지 알 수 있다. 이러한 테이블 구조를 보고 객체의 연관관계를 생각해본다면 Member클래스에서 team을 다대일로 매핑해줌으로써 멤버가 어떤 팀에 소속되는지 알 수 있다.
teamId를 단순하게 Long형의 아이디값으로 갖는 것이 아니라 @ManyToOne 을 해줌으로써 다대일 매핑을 해줄 수 있다. 테이블 구조로 생각해보면 FK키가 있는 쪽이 항상 N, 즉 '다' 가 된다. 객체 연관관계에서도 N인 쪽을 @ManyToOne해주고, @JoinColumn(name = "TEAM_ID) 해주어서 외래키로 사용할 컬럼과 그 컬럼의 이름을 정해주는 것이다.
이와 같이 사용하면 된다.
TeamA를 하나 만들어서 영속성 컨텍스트에 영속을 시켜주고, memberA을 만들어서 영속성 컨텍스트에 영속을 시켜주는데 이 때, member.setJpaTeam(team); 해서 멤버를 팀에다가 넣어주면 된다.
em.persist(team); 을 하면 JpaTeam의 id가 @GeneratedValue이기 때문에 먼저 DB에 들러서 PK값을 얻어온 후 영속성 컨텍스트에 저장을 한다.
그 후에 member를 만들어서 member.setJpaTeam(team); 하게 되면 JPA가 위에서 걸어준 @ManyToOne 애노테이션과 @JoinColumn을 보고 연관관계를 파악하여 자동으로 PK값을 FK로 사용을 한다.
조회 할때에는 em.find(Member.class, member.getId()); 하여 findMember를 만들고, findMember.getTeam(); 하여 바로 사용할 수 있다.
이 때, DB로 날아가는 쿼리를 보면 Member와 Team을 Join해서 select하는 것을 볼 수 있다.
양방향 연관관계
위의 단방향 연관관계와 비교해보면 테이블 연관관계는 똑같다.
양방향 연관관계를 매핑해주는 작업을 해도 테이블에는 영향이 없는 것이다. 가장 좋은 설계는 단방향 연관관계로 설계하는 것이고, 필요시에 양방향 연관관계를 걸어주는 것이 바람직하다.
테이블 연관관계에서 보면 TEAM_ID를 FK로 가짐으로서 멤버가 어떤 팀에 소속되었는지 알 수 있었고, 또 반대로 팀에 어떤 멤버들이 소속되어 있는지도 알 수 있다. 테이블은 사실 방향의 개념이 없는 것이다.
단방향 연관관계를 생각해보면, Member클래스에는 TEAM_ID를 JoinColumn으로 하는 필드를 만들어줌으로써 멤버가 어떤 팀에 속했는지 알 수 있지만, 반대로 Team에서는 Member로 바로 접근이 불가능했었다. 이것이 바로 테이블과 객체 사이의 차이점이다.
양방향 매핑에서는 Team 엔티티에 컬렉션을 추가해 주어야 한다. Team 엔티티에 List를 추가해줌으로써 이 팀의 어떤 멤버가 있는지 담을 수 있는 것이다.
Team은 Member와 일대다 관계이다. 즉 '1' 에 해당됨으로 @OneToMany 애노테이션을 붙여주면 된다. 여기서 중요한 것은 mappedBy 속성이다. mappedBy는 말 그대로 '나는 누구에게 매핑되어져 있다'를 표시해주는 것이다. 여기에서 "team"은 내가 매핑당하고 있는 FK값이 되는 변수명이다.
위와 같이 양방향 연관관계 매핑이 된것을 조회해볼 수 있다.
em.find(Member.class, member.getId()); 하여 findMember를 만들고, 이 findMember가 속한 팀을 getJpaTeam() 하여 가져오고 그 팀의 getMembers() 하여 컬렉션을 얻어올 수 있는 것이다.
Team에 컬렉션을 만들어서 서로 양방향 매핑을 해줌으로써 이런 조회가 가능해 진 것이다.
그런데, 중요한것이 있다.
아까 mappedBy를 Team에다가 써주었었는데, 그러면 mappedBy는 대체 어느 방향에 써주어야 하는 것일까?
우선 왜 mappedBy와 같은 것을 써줘야 하는지 이해를 해보자.
객체의 양방향 관계는 사실 양방향 관계가 아니라 서로 다른 단방향 관계 2개이다. 객체를 양방향으로 참조하려면 위의 사진처럼 단방향인 연관관계를 2개 만들어야 하는 것이다.
반면에 테이블을 생각해보면 테이블은 외래키 하나로 두개의 테이블의 연관관계를 관리할 수 있다. MEMBER.TEAM_ID 외래키 하나로 양방향 연관관계를 가지게 되는것이다.
그렇다면 결국, 객체는 Team team든지 List members 든지, 둘중에 하나로 외래 키를 관리해야하는 것이다.
그래서 가장 중요한 것이 바로 연관관계의 주인(Owner)를 정하는 것이다!
연관관계의 주인
- 양방향 매핑 규칙이다. - 객체의 두 관계중 하나를 연관관계의 주인으로 지정해야 한다. - 연관관계의 주인만이 외래키를 관리(등록, 수정)한다. - 주인이 아닌쪽은 읽기만 가능하다. - 주인은 mappedBy 속성을 사용하지 않는다. - 주인이 아니면 mappedBy 속성으로 주인을 지정한다. - 외래 키가 있는 곳을 주인으로 정해라.(중요!!!) - ManyToOne(1:N에서 N)인 곳이 주인이 된다. (중요!!!) - 자동차와 자동차 바퀴가 있으면, 물론 자동차가 비즈니스적으로는 중요하지만, 자동차의 주인은 바퀴! 가 된다고 기억하자.
양방향 매핑시 가장 많이 하는 실수
역방향(주인이 아닌 방향)만 연관관계 설정을 하면 외래키 값이 null이 된다. 왜?! Team에 있는 mappedBy된 것은 읽기 전용이다. JPA에서 update할때나 insert할때는 이 객체를 보지 않는다. 즉, JpaMember에 있는 jpateam이 주인인데, 주인이 아닌곳에 값을 넣었기 때문에 null 이 나오는 것이다. 양방향 매핑 시 연관관계의 주인에 값을 입력해야 한다.
위의 코드에서 member.setJpaTeam(team)까지 하면 사실 JPA입장에선 맞는 코드이다. Team만들고 영속성 컨텍스트에 올리고, 디비에서 가져온 키값을 (1차캐시에 있는) 외래키로 가져와서 setTeam해주면 문제가 없기 때문이다. 그 이후에 getMembers(); 하여 호출하게 되면 JPA는 실제 사용하는 시점이라고 판단하여 Member에 대한 select 쿼리를 또 한번 날려준다.
그런데, 순수한 객체 관계를 고려하면 항상 양쪽 다 값을 입력해야 한다. 왜 그럴까?
문제 1 . flush(), clear()가 없으면?
-> 1차 캐시에 있는것을 find해준다. 그러면 컬렉션에 값이 없다.. 객체지향적으로 생각했을 때 team, member에 모두 값을 넣어주는게 맞다.
문제2. 테스트 케이스 작성할 때도 같은 문제
-> 위의 상황과 비슷하게 member는 조회가 되는데 team은 없는 상황이 나올수도 있다.
양방향 연관관계가 있으면 양쪽의 값을 모두 넣어주어야 하는 번거로움을 조금이라도 줄이고자 연관관계 편의 메서드를 사용해도 좋다.
예제에서 member.setJpateam(team); 하면 set할때 team참조를 저장하여 연관관계를 설정해주었다. 이 때, 나 자신의 인스턴스(this, 여기서는 member객체)를 mappedBy쪽의 컬렉션에 add를 해주는 것이다. 이렇게 하면 set과 동시에 양쪽의 값을 모두 넣어줄 수 있다.
반대로 team에 member를 추가할 때 연관관계 편의 메서드를 사용할 수도 있다.
임의로 addMember 메서드를 만들어서 member.setJpateam(this);로 현재 team객체를 set에 넣고, 컬렉션에 member를 add해주면 양쪽의 값을 모두 넣어줄 수 있다.
두 방법중 정답은 없다. 이 예제에서 보면 팀을 만들 때 그 팀에 속한 멤버를 넣어줄 수도 있는 것이고, 멤버를 만들 때 그 멤버가 속할 팀을 정해줄 수도 있는 것이다. 개발할 비즈니스 모델에 맞추어서 만들면 된다.
단, 2가지를 한번에 사용하면 충돌이 일어날 수 있기 때문에 한가지 방법만 사용하자.
추가적으로 주의할 것!
- toString(), lombok, JSON생성 라이브러리 를 사용할 때 무한루프를 조심하자.
-> 양방향 관계인 양쪽 엔티티에 모두 toString()을 Override하여 사용하면 컬렉션이 있는 쪽의 엔티티 안의 값들을 호출하게 되고 서로 무한으로 왔다갔다 호출이 된다. 롬복 라이브러리의 @Data 애노테이션을 사용하면 @ToString 을 포함하고 있어서 자동으로 toString()을 오버라이딩하기 때문에 주의해야 한다.
- JSON생성 라이브러리로 인한 문제 : 컨트롤러에서 entity를 반환하지 말자! 엔티티를 변경하면 API 스펙이 바뀐다. dto로 변환해서 반환하는것을 추천.