[Spring] JPQL (JPA Query Language)과 쿼리 메서드
JPQL (JPA Query Language) JPQL은 JPA Query Language의 줄임말로 JPA에서 사용할 수 있는 쿼리를 의미합니다. JPQL의 문법은 SQL과 매우 유사하여 데이터베이스 쿼리에 익숙하다면 쉽게 사용할 수 있습니다. SQL과의 차이점은 SQL에서는 테이블이나 칼럼의 이름을 사용하는 것과 달리 JPQL은 엔티티 객체를 대상으로 수행하는 쿼리이기 때문에 매핑된 엔티티의 이름과 필드의 이름을 사용해야 합니다. SELECT p FROM Product p WHERE p.number = ?1; 위 문장에서 Product는 엔티티 클래스 이름, p.number는 엔티티의 속성입니다. 리포지토리 쿼리 메서드 생성하기 쿼리 메서드는 크게 동작을 결정하는 주제(Subject)와 서술어(Pre..
2024.02.28
no image
[Database]DB 정규화(normalization)개념과 정규화 과정(Normal form), 1NF ~ BCNF
데이터베이스 정규화와 Normal Form 테이블 정규화는 중복 데이터와 데이터 업데이트 충돌을 최소화하기 위해, 일련의 normal forms(NF)에 따라 relational DB를 구성하는 과정 Normal Form(NF)이란 정규화 과정에서 준수해야 하는 규칙들이며, 처음부터 순차적으로 진행하며 normal form을 해당 규칙을 모두 만족할 때 1NF부터 BCNF까지 적용할 수 있다. 1NF와 BCNF는 functional dependency와 key로만 정의될 수 있다. 1NF부터 BCNF까지 를 만족하면 데이터베이스가 정규화됐다고 말하기도 하며, 일반적으로 실무에서는 3NF 혹은 BCNF까지만 진행하는 경우가 많다. 5NF나 6NF 같은 경우에는 대부분의 경우 발생하지 않으며, 상당히 복잡하..
2024.02.26
no image
[Tip] KPT(Keep, Problem, Try) 방식으로 프로젝트 회고하기
회고(retrospective)를 해보자 프로젝트가 끝나면 회고를 하는 것이 중요합니다. 이번에 한 프로젝트의 좋은 점과 고칠 점을 파악하여, 다음 프로젝트에서 더 나은 결과물을 만드는데 도움이 되기 때문입니다. 과거에 발생한 문제를 미래에 대비하기 위해 새로운 방법과 넓은 시야를 얻을 수 있고, 스스로 성장할 기회를 얻을 수 있습니다. 이 글에서는 회고를 하는 방법중 하나인 KPT(Keep, Problem, Try) 회고 방법에 대해서 소개하려 합니다. K-P-T(Keep, Problem, Try) Keep 이 단계에서는 프로젝트를 진행하면서 실용적이거나, 성공적인 성과들을 나열합니다. 긍정적인 요소들, 가치있는 배움들이 포함될 수 있습니다. 예를 들어, 다음 내용이 포함될 수 있습니다. 꾸준히 테스트..
2024.02.25
no image
[Development] 테스트 주도 개발 (Test-Driven Development)
테스트 주도 개발 (Test-Driven Development) TDD란 Test-Driven Development의 줄임말로 '테스트 주도 개발'이라는 의미를 가지고 있습니다. 테스트 주도 개발은 반복 테스트를 이용한 소프트웨어 개발 방법론으로서 테스트 코드를 먼저 작성한 후 테스트를 통과하는 코드를 작성하는 과정을 반복하는 소프트웨어 개발 방식입니다. 애자일 방법론 중 하나인 익스트림 프로그래밍(eXtream Programming)의 Test-First 개념에 기반을 둔, 개발 주기가 짧은 개발 프로세스로 단순한 설계를 중시합니다. 애자일 소프트웨어 개발 방법론(Agile Development)과 익스트림 프로그래밍(Extreme Programming) 애자일은 신속한 반복 작업을 통해 실제 작동 가..
2024.02.25
no image
[개발 일기] 이게 왜 null이 아니야 (영속성 컨텍스트에서 같은 Entity를 참조!)
테스트 코드 공부 중에 의아한 점.Product givenProduct = Product.builder() .name("note") .price(1000) .stock(500) .build(); System.out.println("before save = " + givenProduct.getNumber()); // when Product savedProduct = productRepository.save(givenProduct); // then System.out.println("after save = " + givenProduct.ge..
2024.02.24
[Spring] 스프링 부트에서 테스트 코드 작성하기
스프링 부트의 테스트 설정 스프링 부트는 테스트 환경을 쉽게 설정할 수 있도록 spring-boot-starter-test 프로젝트를 지원한다. 이 프로젝트를 사용하려면 의존성을 추가해야 한다. Gradle (build.gradle) dependencies { ... testImplementation 'org.springframework.boot:spring-boot-starter-test' } Maven (pom.xml) org.springframework.boot spring-boot-starter-test test 스프링 부트에서 제공하는 spring-boot-starter-test 라이브러리는 JUnit, Mockito, assertJ등의 다양한 테스트 도구를 제공한다. spring-boot-st..
2024.02.23
[Java] Logback - 로깅 라이브러리
Logging Logging(로깅)이란 애플리케이션이 동작하는 동안 시스템의 상태나 동작 정보를 시간순으로 기록하는 것을 의미합니다. 로깅은 개발 영역 중 '비기능 요구사항'에 속합니다. 즉 필수적인 기능은 아니나, 로깅은 디버깅하거나 갭라 이후 발생한 문제를 해결할 때 원인을 분석하는 데 유용합니다. 자바 진영에서 가장 많이 사용되는 Logging 프레임워크는 Logback입니다. Logback Logback은 *log4j 이후에 출시된 로깅 프레임워크로서 *slf4j를 기반으로 구현됐으며, 과거에 사용되던 Log4j에 비해 월등한 성능을 자랑합니다. *log4j: Apache log4j는 Java 기반의 로깅 유틸리티로서, 로그를 파일, 콘솔, HTML, 데이터베이스 등 다양한 대상에 출력할 수 있습..
2024.02.22
no image
[Java] immutable(불변) 객체란?
자바에서 불변(Immutable) 객체에 대한 이해 가변 객체는 상태 정보를 변경할 수 있어 불안정하고, 작업 전후에 일일이 코드를 확인하거나 DB를 호출해야한다. 하지만 불변 객체는 한번 만들어지면 상태가 변경되지 않아 안정적인 개발이 가능하다. 불변(Immutable)객체는 생성 이후에 상태가 변하지 않는 객체이다. 불변 객체는 중복 제거의 장점을 가지며, 안전한 서비스 개발에 도움이 된다. 예를 들어 DB에서 객체 정보를 받아와 작업할 때, 의도치 않게 불변 객체의 정보를 변경하려는 경우 불변 객체를 사용하면 이를 방지할 수 있다. 또한 map, set, cache에 쓰기에 적절하다. 불변 객체를 사용하면 Thread-safe의 장점이 있다. 데이터 불일치 역시 없어, 안전하게 여러 셀에서 상태정보..
2024.02.22

JPQL (JPA Query Language)

JPQL은 JPA Query Language의 줄임말로 JPA에서 사용할 수 있는 쿼리를 의미합니다. JPQL의 문법은 SQL과 매우 유사하여 데이터베이스 쿼리에 익숙하다면 쉽게 사용할 수 있습니다.

SQL과의 차이점은 SQL에서는 테이블이나 칼럼의 이름을 사용하는 것과 달리 JPQL은 엔티티 객체를 대상으로 수행하는 쿼리이기 때문에 매핑된 엔티티의 이름과 필드의 이름을 사용해야 합니다.

SELECT p FROM Product p WHERE p.number = ?1;

위 문장에서 Product는 엔티티 클래스 이름, p.number는 엔티티의 속성입니다.

리포지토리 쿼리 메서드 생성하기

쿼리 메서드는 크게 동작을 결정하는 주제(Subject)와 서술어(Predicate)로 구분합니다. 'find... By', 'exists... By'와 같은 키워드로 주제를 정하며 'By'는 서술어의 시작을 나타내는 구분자 역할을 합니다. 서술어 부분은 검색 및 정렬 조건을 지정하는 영역입니다. 기본적으로 엔티티의 속성으로 정의할 수 있고, AND나 OR를 사용해 조건을 확장하는 것도 가능합니다.

 

리포지토리의 쿼리 메서드 생성 예시

// (리턴타입) + {주제 + 서술어(속성)} 구조의 메서드
List<Person> findByLastNameAndEmail(String lastName, String email);

쿼리 메서드의 주제 키워드

조회 키워드

  • find ~ By: 하나의 엔티티를 조회하는데 사용, 못 찾으면 null 리턴
  • get ~ By: 하나의 엔티티를 조회하는데 사용, 지연 로딩을 사용하여 프록시 객체를 얻음
  • search ~ By: 조건을 정의하여 엔티티들의 리스트를 조회하는데 사용
  • stream ~ By: Stream 타입으로 엔티티들을 조회하는데 사용

exists ~ By

특정 데이터가 존재하는지 확인하는 키워드입니다.

boolean existsByNumber(Long number);

 

count ~ By

조회 쿼리를 수행한 후 쿼리 결과로 나온 레코드의 개수를 리턴합니다.

long CountByName(String name);

 

delete ~ By, remove ~ By

삭제 쿼리를 수행합니다. 

 

~First{number}~, ~Top{number}~

쿼리를 통해 조회된 결괏값의 개수를 제한하는 키워드입니다. 

List<Product> findFirst5ByName(String name);
List<Product> findTop10ByName(String name);

조건자 키워드

Is

값의 일치를 조건으로 사용하는 조건자 키워드입니다. Equals와 동일한 기능을 합니다.

Product findByNumberIs(Long number);
Product findByNumberEquals(Long number);

 

(Is)Not

값의 불일치를 조건으로 사용하는 조건자 키워드입니다.

Product findByNumberIsNot(Long number);
Product findByNumberNot(Long number);

 

(Is)Null, (Is)NotNull

값이 Null인지 검사하는 조건자 키워드입니다.

List<Product> findByUpdatedAtNull();
List<Product> findByUpdatedAtNotNull();

 

 

(Is)True, (Is)False

boolean 타입으로 지정된 칼럼값을 확인하는 키워드입니다.

Product findByisActiveTrue();
Product findByisActiveFalse();

 

And, Or

여러 조건을 묶을 때 사용합니다.

Product findByNumberAndName(Long number, String name);
Product findByNumberOrName(Long number, String name);

(Is)GreaterThan, (Is)LessThan, (Is)Between

숫자나 datetime 칼럼을 대상으로 한 비교 연산에 사용할 수 있는 조건자 키워드입니다. GreaterThan, LessThan 키워드는 비교 대상에 대한 초과/미만의 개념으로 비교 연산을 수행하고, 경곗값을 포함하려면 Equal 키워드를 추가하면 됩니다.

List<Product> findByPriceIsGreaterThan(Long price);
List<Product> findByPriceIsLessThanEqual(Long price);
List<Product> findByPriceIsBetween(Long lowPrice, Long highPrice);

 

(Is)StartingWith(StartsWith), (Is)EndingWith(EndsWith), (Is)Containing(Contains), (Is)Like

칼럼값에서 일부 일치 여부를 확인하는 조건자 키워드입니다. SQL 쿼리문에서 값이 일부를 포함하는 값을 추출할 때 사용하는 % 키워드와 동일한 역할을 하는 키워드입니다.

Containing 키워드는 문자열의 양 끝, StartingWith 키워드는 문자열의 앞, EndingWith 키워드는 문자열의 끝에 %가 배치됩니다.

 List<Product> findByNameStartingWith(String namePrefix); 
 List<Product> findByDescriptionEndingWith(String descriptionSuffix);
 List<Product> findByNameContaining(String nameSubstring);

Like 키워드는 인자로 %를 명시적으로 입력해야 합니다. 

List<Product> findByNameLike(String namePattern); // ex) namePattern = "Appl%"

 

참고

  • 스프링 부트 핵심 가이드 "스프링 부트를 활용한 애플리케이션 개발 실무" , 장정우, 2022

'Development > Spring' 카테고리의 다른 글

[Spring] QueryDSL  (2) 2024.03.01
[Spring] JPA의 정렬과 페이징 처리  (0) 2024.02.29
[Spring] 스프링 부트에서 테스트 코드 작성하기  (0) 2024.02.23
[Spring] Lombok  (0) 2024.02.21
[Spring] OpenSessionInViewFilter  (0) 2024.02.21

데이터베이스 정규화와 Normal Form

  • 테이블 정규화는 중복 데이터와 데이터 업데이트 충돌을 최소화하기 위해, 일련의 normal forms(NF)에 따라 relational DB를 구성하는 과정
  • Normal Form(NF)이란 정규화 과정에서 준수해야 하는 규칙들이며, 처음부터 순차적으로 진행하며 normal form을 해당 규칙을 모두 만족할 때 1NF부터 BCNF까지 적용할 수 있다.
  • 1NF와 BCNF는 functional dependency와 key로만 정의될 수 있다.
  • 1NF부터 BCNF까지 를 만족하면 데이터베이스가 정규화됐다고 말하기도 하며, 일반적으로 실무에서는 3NF 혹은 BCNF까지만 진행하는 경우가 많다.
  • 5NF나 6NF 같은 경우에는 대부분의 경우 발생하지 않으며, 상당히 복잡하므로 많이 해도 4NF까지만 적용한다.

️Super Key, Candidate Key, Primary Key, Prime attribute

  • super key는 tuple들을 유니크하게 식별할 수 있는 attributes set
  • candidate key는 어느 한 attribute라도 제거하면 unique하게 tuples를 식별할 수 없는 super key
  • primary key는 테이블에서 tuple들을 유니크하게 식별하려고 선택된 candidate key
  • prime attribute는 임의의 Key에 속하는 attribute
  • non-prime attribute는 어떤 Key에도 속하지 않는 attribute

functional dependency

  • Functional dependency는 레프트 핸드 사이드 애트리뷰트에 따라 라이트 사이드 애트리뷰트가 한정되는 관계를 나타낸다.
  • 디펜던시를 통해 테이블의 튜플들을 유니크하게 식별할 수 있다.
  • functional dependency를 통해 두 attribute의 값이 동일하면 다른 attribute의 값도 동일하다.
  • 등급이란 애트리뷰트에 대한 functional dependency가 존재하여 각은행의 등급을 바로 알 수 있다.
  • 클래스가 같으면 뱅크 네임도 항상 같은 functional dependency가 존재한다.
  • 정규화를 통해 키마를 놓고 중복 데이터를 줄일 수 있는 방법을 보여준다.

Normalization

1NF

attribute의 value는 반드시 나눠질 수 없는 단일한 값이어야 한다.

위 테이블에서 수학, 영어와 같은 여러 데이터가 한 행에 존재한다. 이를 1NF는 분리된 행으로 수정한다.

 

2NF

모든 Non-prime attribute는 모든 key에 Fully functionally dependent 해야 한다.

위 테이블에서 학번과 과목이 Primary Key라고 하면 Non-Prime attribute는 성적과 과목코드이다.

여기서 성적은 학번과 과목에 fully functionally dependent 하다. 학번과 과목에 따라 성적이 달라지기 때문이다.

그런데 과목코드는 그렇지 않다. 오직 학번과 상관없이 과목에 대해서만 의존하므로, 2NF를 위반한다.

즉 {학번, 과목} -> {성적}은 fully functionally dependent하지만, {과목} -> {과목코드} 이므로 partially dependent하므로 2NF를 위반한다. 

따라서 다음과 같이 수정할 수 있다.

2NF는 key가 composite key가 아니라면 자동으로 만족한다?

모든 Non-prime attribute는 모든 key에 Fully functionally dependent 해야 한다.
모든 Non-prime attribute는 어떤 key에도 partially dependent 하면 안된다.

일부는 맞고 일부는 틀린 얘기이다. 

{empl_id} -> {empl_name, birth_date, company} 라는 함수 종속이 있다고 치고,

{} -> {company} (company가 전부 동일한 값을 가질때)를 동시에 만족한다고 치면 company는 empl_id에 partially dependent 하므로 2NF를 위반한다는 것이다.

 

3NF

모든 Non-prime attribute는 어떤 key에도 transitively dependent 하면 안된다.
즉 non-prime attribute와 non-prime attribute 사이에는 FD가 있으면 안된다는 것.

transitively dependent란, Y 또는 Z가 어떠한 key의 부분 집합이 아닐때 X -> Y && Y -> Z를 만족하면, X -> Z를 만족하는 함수 종속을 말한다.

Violating 3NF table

위 테이블에서 Purchase IDCustomer ID가 prime attribute이고, Album TitleArtist Name이 non-prime attribute라고 친다면, Album Title과 Artist Name은 Album Title의 값을 의존하므로, non-prime attribute와 non-prime attribute 사이에는 FD가 존재한다. 따라서 3NF를 위반하므로 아래와 같이 수정할 수 있다.

BCNF(Boyce-Codd 정규형)

BCNF(Boyce-Codd Normal Form)는 모든 Non-trivial FD(functional dependency) X -> Y는 X가 Super Key여야 한다는 룰이다.
쉽게 말해 Key가 아닌 Column이 다른 Key 집합 중, 일부를 의존하고 있는 것

Non-trival이란 X -> Y를 만족하는 FD(functional dependency)가 있을때 Y가 X의 부분 집합이 아닌 경우를 말한다.

위 테이블에서 Key는 Course IDDepartment Code이다. 그런데 Non-prime attribute인 Instructor Name이 Key 중 하나인 Department Code를 의존하고 있는 것. 즉 {Instructor Name} -> {Department Code}인데 Instructor Name이 Super Key가 아니므로 BCNF를 위반한다.

따라서 아래와 같이 수정할 수 있다.

 

DepartmentsInstructors테이블을 새로 생성하여, Instructor NameDepartment Code만 의존하도록 수정하였다.

Denormalization (역정규화)

  • 테이블을 너무 많이 나누면 성능과 관리 측면에서 문제가 생길 수 있다.
  • DeNormalization은 테이블을 다시 합치는 과정으로, 중복 데이터와 과도한 조인을 최소화하는 사이에서 적절한 수준을 찾는 것이 중요하다.

참고

이미지 출처: https://i0.wp.com/sdg.neuromagic.com/wp-content/uploads/2021/08/24d5750b9387f80923b511ba43999ff4.png?resize=800%2C650&ssl=1

회고(retrospective)를 해보자

프로젝트가 끝나면 회고를 하는 것이 중요합니다. 이번에 한 프로젝트의 좋은 점과 고칠 점을 파악하여, 다음 프로젝트에서 더 나은 결과물을 만드는데 도움이 되기 때문입니다. 과거에 발생한 문제를 미래에 대비하기 위해 새로운 방법과 넓은 시야를 얻을 수 있고, 스스로 성장할 기회를 얻을 수 있습니다.

 

이 글에서는 회고를 하는 방법중 하나인 KPT(Keep, Problem, Try) 회고 방법에 대해서 소개하려 합니다.

K-P-T(Keep, Problem, Try)

Keep

이 단계에서는 프로젝트를 진행하면서 실용적이거나, 성공적인 성과들을 나열합니다. 긍정적인 요소들, 가치있는 배움들이 포함될 수 있습니다. 예를 들어, 다음 내용이 포함될 수 있습니다.

  • 꾸준히 테스트를 진행하여 유지보수를 한 점
  • 충분한 시간과 예산을 계획한 점
  • 협업에 있어 소통이 부족하지 않았던 점

Problem

이 단계에서는 어려웠던 점, 문제 발생, 비효율적인 측면 등을 겪었던 일들을 나열합니다. 

예를 들어, 다음 내용이 포함될 수 있습니다.

  • 불충분한 문서화로 프로그램 구현에 있어서 오해가 생겼던 점
  • 불충분한 소통으로 프로젝트 진행에 딜레이가 생겼던 점
  • 시간과 예산에 비해 해야할 범위가 너무 넓었던 점

Try

이 단계에서는 다음 프로젝트 진행에서 시도할 것들을 나열합니다. 이전 프로젝트에서의 경험을 바탕으로, 더 나은 프로젝트 진행을 위해 무엇을 해볼 것인지에 내용이 포함될 수 있습니다. 예를 들어, 다음 내용이 포함될 수 있습니다.

  • 프로젝트를 진행하면서 중간 중간 미팅이나 작업 내용을 공유하는 시간 갖기
  • 효율적인 협업 툴을 적용시켜보기
  • 이해하기 쉬운 문서화 툴 사용해보기

이러한 K-P-T 단계를 거쳐 회고를 작성하면 간단하게 중요 내용들을 포함시켜 회고를 작성할 수 있습니다.

 

테스트 주도 개발 (Test-Driven Development)

TDD란 Test-Driven Development의 줄임말로 '테스트 주도 개발'이라는 의미를 가지고 있습니다. 테스트 주도 개발은 반복 테스트를 이용한 소프트웨어 개발 방법론으로서 테스트 코드를 먼저 작성한 후 테스트를 통과하는 코드를 작성하는 과정을 반복하는 소프트웨어 개발 방식입니다. 애자일 방법론 중 하나인 익스트림 프로그래밍(eXtream Programming)의 Test-First 개념에 기반을 둔, 개발 주기가 짧은 개발 프로세스로 단순한 설계를 중시합니다.

애자일 소프트웨어 개발 방법론(Agile Development)과 익스트림 프로그래밍(Extreme Programming)

애자일은 신속한 반복 작업을 통해 실제 작동 가능한 소프트웨어를 개발하는 개발 방식입니다. 

 

애자일 소프트웨어 개발 방법론의 핵심은 신속한 개발 프로세스를 통해 수시로 변하는 고객의 요구사항에 대응해서 제공하는 서비스의 가치를 극대화하는 것 입니다. 개발 작업에서 반복 작업을 통해 지속적인 피드백과 수용으로 변화가 빠르고 유연한 개발이 가능해집니다.

 

Extreme Programming은 에자일 방법론으로 간주되며, 에자일 방법론 보다 더 명확하게 규정한 방법론입니다.

익스트림 프로그래밍에선 페어 프로그래밍, 테스트 주도 개발과 같은 단계가 포함될 수 있습니다.

테스트 주도 개발의 개발 주기

이미지 출처: https://www.linkedin.com/pulse/test-driven-development-tdd-michael-halim

  1. 실패 테스트 작성 (Write a failing test, TEST FAILS): 실패하는 테스트 코드를 먼저 작성합니다.
  2. 테스트를 통과하는 코드 작성 (Make a test pass, TEST PASSES): 테스트 코드를 성공시키기 위한 실제 코드를 작성합니다.
  3. 리팩토링 (Refactor): 중복 코드를 제거하거나 일반화하는 리팩토링을 수행합니다.

일반적인 개발 방법은 설계를 진행한 후 그에 맞게 애플리케이션 코드를 작성하고 마지막으로 테스트 코드를 자성하는 흐름으로 진행됩니다. 반면 테스트 주도 개발에서는 설계 이후 바로 테스트 코드를 작성하고 애플리케이션 코드를 작성한다는 점에서 차이가 있습니다.

테스트 주도 개발의 효과

디버깅 시간 단축

테스트 코드 기반으로 개발이 진행되기 때문에 문제가 발생했을 때 어디에서 잘못됐는지 확인하기가 쉽습니다.

 

생산성 향상

테스트 코드를 통해 지속적으로 애플리케이션 코드의 불안정성에 대한 피드백을 받기 때문에 리팩토링 횟수가 줄고 생산성이 높아집니다.

 

재설계 시간 단축

작성돼 있는 테스트 코드를 기반으로 코드를 작성하기 때문에 재설계가 필요할 경우 테스트 코드를 조정하는 것으로 재설계 시간을 단축할 수 있습니다.

 

기능 추가와 같은 추가 구현이 용이

테스트 코드를 통해 의도한 기능을 미리 설계하고 코드를 작성하기 때문에 목적에 맞는 코드를 작성하는 데 비교적 용이합니다.

 

테스트 주도 개발의 단점

익숙치 않은 개발 방법

먼저 코드를 구현하고 테스트를 작성하는 것에 더 친숙하기 때문에, 처음엔 배우기가 어려울 수 있고 이는 생산성을 오히려 떨어뜨릴 수 있습니다.

 

과도한 테스트 커버리지

테스트 코드에 집중한 나머지 너무 과도하게 테스트 코드를 작성하여 오히려 개발의 속도가 늦어질 수 있습니다.

 

자유도 하락

테스트 코드에 맞게 코드를 작성하려다 보면, 다른 접근법이나 솔루션을 떠올리고 코드를 작성하기가 제한될 수 있습니다.

 

참고

  • 스프링 부트 핵심 가이드 "스프링 부트를 활용한 애플리케이션 개발 실무" , 장정우, 2022

테스트 코드 공부 중에 의아한 점.

Product givenProduct = Product.builder()
                .name("note")
                .price(1000)
                .stock(500)
                .build();

        System.out.println("before save = " + givenProduct.getNumber());

        // when
        Product savedProduct = productRepository.save(givenProduct);

        // then
        System.out.println("after save = " + givenProduct.getNumber());

위 출력 결과는 다음과 같다.

before save = null
Hibernate: 
    insert 
    into
        product
        (created_at, name, price, provider_id, stock, updated_at, number) 
    values
        (?, ?, ?, ?, ?, ?, default)
after save = 1

그런데 after save에는 왜 null이 아닐까?

저장된 savedProduct는 Id 값을 할당받으니 savedProduct에만 Id 값이 있어야 하는거 아닌가?

그런데 givenProduct의 number를 출력해봤더니 null이 아닌 1이 출력되는 것이다.

이유

productRepository.save(givenProduct)를 호출하면 다음 순서로 진행된다.

  1. JPA 또는 Hibernate는 영속성 컨텍스트에 이미 존재하는 엔티티인가? 없으면 올려놓는다.
  2. 그리고 save()를 통해 데이터베이스로부터 새로 저장된 엔티티를 받아서 영속성 컨텍스트에 이미 같은 엔티티가 존재하면 merge 한다.
  3. givenProduct와 savedProduct는 같은 엔티티이기 때문에, 결과적으로 같은 메모리의 객체를 참조하게 된다.
  4. 그래서 givenProduct에도 Id 값이 반영되는 것.

디버그 모드로 확인해 본 모습. 

스프링 부트의 테스트 설정

  • 스프링 부트는 테스트 환경을 쉽게 설정할 수 있도록 spring-boot-starter-test 프로젝트를 지원한다.
  • 이 프로젝트를 사용하려면 의존성을 추가해야 한다.

Gradle (build.gradle)

dependencies {
	...
	testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

 

Maven (pom.xml)

<dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
</dependency>

 

스프링 부트에서 제공하는 spring-boot-starter-test 라이브러리는 JUnit, Mockito, assertJ등의 다양한 테스트 도구를 제공한다.

spring-boot-starter-test 라이브러리에서 제공하는 대표적인 라이브러리는 다음과 같다.

  • JUnit 5: 자바 애플리케이션의 단위 테스트를 지원
  • Spring Test & Spring Boot Test: 스프링 부트 애플리케이션에 대한 유틸리티와 통합 테스트를 지원
  • AssertJ: 다양한 단정문(assert)을 지원하는 라이브러리
  • Hamcrest: Matcher를 지원하는 라이브러리
  • Mockito: 자바 Mock 객체를 지원하는 프레임워크
  • JSONassert: JSON용 단정문 라이브러리
  • JsonPath: JSON용 XPath를 지원

테스트 클래스에서 사용되는 어노테이션들

@SpringbootTest

  • 애플리케이션 컨텍스트(Application Context)를 전체 로드하여 실제 애플리케이션 환경과 유사한 환경에서 테스트를 실행할 수 있다.
  • 통합 테스트에 주로 사용되며, 전체 Spring 컨텍스트를 로드하기 때문에 애플리케이션의 규모가 커질수록 테스트 실행 속도가 느려질 수 있다.

@WebMvcTest(테스트 대상 클래스.class)

  • 웹에서 사용되는 요청과 응답에 대한 테스트를 수행할 수 있다.
  • 대상 클래스만 로드해 테스트를 수행하며, 만약 대상 클래스를 추가하지 않으면 @Controller, @RestController, @ControllerAdvice 등의 컨트롤러 관련 빈 객체가 모두 로드된다.
  • @SpringbootTest 보다 가볍게 테스트하기 위해 사용된다.

@MockBean

  • @MockBean은 실제 빈 객체가 아닌 Mock(가짜) 객체를 생성해서 주입하는 역할을 수행한다.
  • @MockBean이 선언된 객체는 실제 객체가 아니기 때문에 실제 행위를 수행하지 않는다.  그렇기 때문에 Mockito의 given() 메서드를 통해 동작을 정의해야 한다.

@Test

  • 테스트 코드가 포함돼 있다고 선언하는 어노테이션이며, JUnit Jupiter에서는 이 어노테이션을 감지해서 테스트 계획에 포함시킨다.

@DisplayName

  • 테스트 메서드의 이름이 복잡해서 가독성이 떨어질 경우 이 어노테이션을 통해 테스트에 대한 표현을 정의할 수 있다.

각 레이어 별 테스트 (슬라이스 테스트)

  • 일반적으로 @WebMvcTest 어노테이션을 사용한 테스트는 슬라이스(Slice) 테스트라고 부른다.
  • 슬라이스 테스트는 단위 테스트와 통합 테스트의 중간 개념으로 이해하면 되는데, 레이어드 아키텍처를 기준으로 각 레이어별로 나누어 테스트를 진행한다는 의미이다.

Controller

  • 컨트롤러는 클라이언트로부터 요청을 받아 요청에 걸맞은 서비스 컴포넌트로 요청을 전달하고, 그 결괏값을 가공해서 클라이언트에게 응답하는 역할을 수행한다. 
  • Controller는 일반적으로 Service 객체의 의존성을 주입받는다. 이때 Controller만 테스트하려는 경우에 Service 객체는 Mock 객체를 활용하여 외부 요인에 영향을 받지 않도록 해야한다. 
@WebMvcTest(ProductController.class)
class ProductControllerTest {

    // 서블릿 컨테이너의 구동 없이, 가상의 MVC 환경에서 모의 HTTP 서블릿을 요청하는 유틸리티 클래스
    @Autowired
    private MockMvc mockMvc;

    // ProductController에서 잡고 있는 Bean 객체에 대해 Mock 형태의 객체를 생성해줌
    @MockBean
    ProductServiceImpl productService;

    // http://localhost:8080/api/v1/product-api/product/{productId}
    @Test
    @DisplayName("MockMvc를 통한 Product 데이터 가져오기 테스트")
    void getProductTest() throws Exception {

        // given : Mock 객체가 특정 상황에서 해야하는 행위를 정의하는 메소드
        given(productService.getProduct(123L)).willReturn(
            new ProductResponseDto(123L, "pen", 5000, 2000));

        String productId = "123";

        // andExpect : 기대하는 값이 나왔는지 체크해볼 수 있는 메소드
        mockMvc.perform(
                get("/product?number=" + productId))
            .andExpect(status().isOk())
            .andExpect(jsonPath(
                "$.number").exists()) // json path의 depth가 깊어지면 .을 추가하여 탐색할 수 있음 (ex : $.productId.productIdName)
            .andExpect(jsonPath("$.name").exists())
            .andExpect(jsonPath("$.price").exists())
            .andExpect(jsonPath("$.stock").exists())
            .andDo(print());

        // verify : 해당 객체의 메소드가 실행되었는지 체크해줌
        verify(productService).getProduct(123L);
    }

    // http://localhost:8080/api/v1/product-api/product
    @Test
    @DisplayName("Product 데이터 생성 테스트")
    void createProductTest() throws Exception {
        //Mock 객체에서 특정 메소드가 실행되는 경우 실제 Return을 줄 수 없기 때문에 아래와 같이 가정 사항을 만들어줌
        given(productService.saveProduct(new ProductDto("pen", 5000, 2000)))
            .willReturn(new ProductResponseDto(12315L, "pen", 5000, 2000));

        ProductDto productDto = ProductDto.builder()
            .name("pen")
            .price(5000)
            .stock(2000)
            .build();

        Gson gson = new Gson();
        String content = gson.toJson(productDto);

        mockMvc.perform(
                post("/product")
                    .content(content)
                    .contentType(MediaType.APPLICATION_JSON))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.number").exists())
            .andExpect(jsonPath("$.name").exists())
            .andExpect(jsonPath("$.price").exists())
            .andExpect(jsonPath("$.stock").exists())
            .andDo(print());

        verify(productService).saveProduct(new ProductDto("pen", 5000, 2000));
    }

}

 

  • Gson은 구글에서 개발한 JSON 파싱 라이브러리로서 자바 객체를 JSON 문자열로 변환하거나 JSON 문자열을 자바 객체로 변환하는 역할을 한다.

perform(), andExpect()

  • 서버로 URL 요청을 보내는 것처럼 통신 테스트 코드를 작성해서 컨트롤러를 테스트할 수 있다.
  • MockMvcRequestBuilders에서 제공하는 HTTP 메서드로 URL을 정의해서 사용한다.
  • 결과값으로 ResultActions 객체가 리턴되는데, andExpect() 메서드를 사용해 결괏값 검증을 할 수 있다.
  • andExpect()는 ResultMatcher를 활용하는데, 이를 위해 MockMvcResultMatchers 클래스에 정의돼 있는 메서드를 활용해 생성할 수 있다.

MockMvcRequestBuilders

  • Http 메서드인 GET, POST, PUT, DELETE에 매핑되는 메서드를 제공한다.
  • 이 메서드는 MockMVcServletRequestBuilder 객체를 리턴하며, HTTP 요청 정보를 설정할 수 있게 해준다.

Service

아래는 Service 레이어에서 테스트 코드 예시 이다.

@ExtendWith(SpringExtension.class) 
@Import({ProductServiceImpl.class})
public class ProductServiceTest {

	// 스프링 Bean에 Mock 객체를 등록해서 주입받는 방식
    // @MockBean
    // ProductRepository productRepository;

	// 스프링 Bean에 ProductRepository를 등록하지 않고, 직접 객체를 생성하는 방식
    private ProductRepository productRepository = Mockito.mock(ProductRepository.class);
    private ProductServiceImpl productService;

    @BeforeEach
    public void setUpTest() {
        productService = new ProductServiceImpl(productRepository);
    }

    @Test
    void getProductTest() {
        // given
        Product givenProduct = new Product();
        givenProduct.setNumber(123L);
        givenProduct.setName("펜");
        givenProduct.setPrice(1000);
        givenProduct.setStock(1234);

        Mockito.when(productRepository.findById(123L))
            .thenReturn(Optional.of(givenProduct));

        // when
        ProductResponseDto productResponseDto = productService.getProduct(123L);

        // then
        Assertions.assertEquals(productResponseDto.getNumber(), givenProduct.getNumber());
        Assertions.assertEquals(productResponseDto.getName(), givenProduct.getName());
        Assertions.assertEquals(productResponseDto.getPrice(), givenProduct.getPrice());
        Assertions.assertEquals(productResponseDto.getStock(), givenProduct.getStock());

        verify(productRepository).findById(123L);
    }

    // 예제 7.12
    @Test
    void saveProductTest() {
        // given
        Mockito.when(productRepository.save(any(Product.class)))
            .then(returnsFirstArg());

        // when
        ProductResponseDto productResponseDto = productService.saveProduct(
            new ProductDto("펜", 1000, 1234));

        // then
        Assertions.assertEquals(productResponseDto.getName(), "펜");
        Assertions.assertEquals(productResponseDto.getPrice(), 1000);
        Assertions.assertEquals(productResponseDto.getStock(), 1234);

        verify(productRepository).save(any());
    }
}
  • 위 코드에서 any() 는 Mockito의 ArgumentMatchers에서 제공하는 메서드로서 Mock 객체의 동작을 정의하거나 검증하는 단계에서 조건으로 특정 매개변수의 전달을 설정하지 않고, 실행만을 확인하거나 좀 더 큰 범위의 클래스 객체를 매개변수로 전달받는 등의 상황에서 사용한다. 
  • @ExtendWith(SpringExtension.class)를 사용해 JUnit5의 테스트에서 스프링 테스트 컨텍스트를 사용하도록 설정한다.
  • @Import({ProductServiceImpl.class})를 사용해 @Autowired 어노테이션으로 주입받는 ProductSerivce를 주입받는다.

Repository

  • Repository(리포지토리)는 데이터베이스와 가장 가까운 레이어이며, Repository 테스트는 구현하는 목적에 대해 고민하고 작성해야 한다.
  • findById(), save() 같은 기본 메서드에 대한 테스트는 큰 의미가 없다. 왜냐하면 Repository의 기본 메서드는 테스트 검증을 마치고 제공된 것이기 때문이다.
  • 데이터베이스의 연동 여부는 테스트 시 고려해 볼 사항입니다.  데이터베이스는 외부 요인이기 때문에, 단위 테스트를 고려한다면 데이터베이스를 제외할 수 있다.
  • 또는 테스트용 데이터베이스를 사용할 수도 있다. 다만 데이터베이스의 적재를 신경 써야 하는 테스트 환경이라면 잘못된 테스트 코드가 실행되면서 발생할 수 있는 사이드 이펙트를 고려해서 데이터베이스 연동 없이 테스트하는 편이 좋을 수도 있다. 

@DataJpaTest

이 어노테이션이 붙은 테스트 클래스는 JPA와 관련된 설정만 로드해서 테스트를 진행합니다. 기본적으로 @Transactional 어노테이션을 포함하고 있어 테스트 코드가 종료되면 자동으로 데이터베이스의 롤백이 진행된다. 기본값으로 임베디드 데이터베이스를 사용하며, 다른 데이터베이스를 사용하려면 별도의 설정을 거쳐 사용 가능합니다. 

 

@AutoConfigureTestDatabase(replace = 설정값)

@AutoConfigureTestDatabase는 SpringBoot Test에서 데이터베이스 관련 테스트를 진행할 때 사용하는 어노테이션이다.

이 어노테이션이 제공하는 'replace' 속성은 어떤 데이터베이스 설정을 사용할지 정의하는데 사용된다.

'replace' 속성에 설정할 수 있는 값은 다음과 같다.

  1. Replace.NONE: 이 설정은 테스트 환경에서 기본 데이터베이스 연결 설정을 그대로 사용하도록 합니다. 즉, spring.datasource.*에 지정된 데이터베이스 연결을 사용하게 됩니다.
  2. Replace.ANY: 이 설정은 내장형 데이터베이스(H2, HSQL, Derby 등)로 자동 구성을 대체하도록 합니다. 만약 기본 데이터베이스 연결 설정이 내장형 데이터베이스를 가리키고 있지 않다면, 이 설정은 아무런 영향을 주지 않습니다.
  3. Replace.AUTO_CONFIGURED: 이 설정은 테스트 환경에서 자동 구성된 데이터베이스를 사용하도록 합니다. 이는 기본적으로 Replace.ANY와 같지만, 테스트 환경에서 데이터베이스 연결을 재정의하지 않으면 내장형 데이터베이스를 사용하도록 합니다.

따라서, '@AutoConfigureTestDatabase(replace = 설정값)'를 이용하면 테스트 환경의 데이터베이스 설정을 유연하게 변경할 수 있다.

@DataJpaTest
@AutoConfigureTestDatabase(replace = Replace.NONE)
class ProductRepositoryTest {

    @Autowired
    private ProductRepository productRepository;

    @Test
    void save() {
        // given
        Product product = new Product();
        product.setName("펜");
        product.setPrice(1000);
        product.setStock(1000);

        // when
        Product savedProduct = productRepository.save(product);

        // then
        assertEquals(product.getName(), savedProduct.getName());
        assertEquals(product.getPrice(), savedProduct.getPrice());
        assertEquals(product.getStock(), savedProduct.getStock());
    }

}

참고

Logging

Logging(로깅)이란 애플리케이션이 동작하는 동안 시스템의 상태나 동작 정보를 시간순으로 기록하는 것을 의미합니다.

로깅은 개발 영역 중 '비기능 요구사항'에 속합니다. 즉 필수적인 기능은 아니나, 로깅은 디버깅하거나 갭라 이후 발생한 문제를 해결할 때 원인을 분석하는 데 유용합니다.

자바 진영에서 가장 많이 사용되는 Logging 프레임워크는 Logback입니다.

Logback

Logback은 *log4j 이후에 출시된 로깅 프레임워크로서 *slf4j를 기반으로 구현됐으며, 과거에 사용되던 Log4j에 비해 월등한 성능을 자랑합니다.

*log4j: Apache log4j는 Java 기반의 로깅 유틸리티로서, 로그를 파일, 콘솔, HTML, 데이터베이스 등 다양한 대상에 출력할 수 있습니다.

*slf4j (Simple Logging Facade for Java): slf4j는 여러 로깅 프레임워크(log4j, java.util.logging, Logback 등) 사이의 추상화 된 퍼사드(facade) 또는 래퍼(wrapper)를 제공합니다. 이를 통해 개발자는 로깅 프레임워크를 쉽게 교체할 수 있습니다.

Logback의 특징은 다음과 같습니다.

  • 크게 5개의 로그 레벨(TRACE, DEBUG, INFO, WARN, ERROR)를 설정할 수 있습니다.
  • 실제 운영 환경과 개발 환경에서 각각 다른 출력 레벨을 설정해서 로그를 확인할 수 있습니다.
  • Logback의 설정 파일을 일정 시간마다 스캔해서 애플리케이션을 재가동하지 않아도 설정을 변경할 수 있습니다.
  • 별도의 프로그램 지원 없이도 자체적으로 로그 파일을 압축할 수 있습니다.
  • 저장된 로그 파일에 대한 보관 기간 등을 설정해서 관리할 수 있습니다.

Logback의 로그 레벨

  • ERROR: 로직 수행 중에 시스템에 심각한 문제가 발생해서 애플리케이션의 작동이 불가능한 경우를 의미합니다.
  • WARN: 시스템 에러의 원인이 될 수 있는 경고 레벨을 의미합니다.
  • INFO: 애플리케이션의 상태 변경과 같은 정보 전달을 위해 사용됩니다.
  • DEBUG: 애플리케이션의 디버깅을 위한 메시지를 표시하는 레벨을 의미합니다.
  • TRACE: DEBUG 레벨보다 더 상세한 메시지를 표현하기 위한 레벨을 의미합니다.

Logback의 영역

Logback은 Property, Appender, Encoder, Pattern, Root 요소로 나뉩니다.

Property

  • Logback 설정에서 사용할 수 있는 속석을 정의하는 영역입니다.
  • 여기서 정의된 속성은 설정 파일내에서 재사용될 수 있습니다.

Appender

Appender 영역은 로그의 형태를 설정하고 어떤 방법으로 출력할지를 설정하는 곳입니다.

Appender 자체는 하나의 인터페이스를 의미하며, 하위에 여러 구현체가 존재합니다.

Logback의 설정 파일을 이용하면 각 구현체를 등록해서 로그를 원하는 형식으로 출력할 수 있습니다.

Appender의 대표적인 구현체는 다음과 같습니다.

  • ConsoleAppender: 콘솔에 로그를 출력
  • FileAppender: 파일에 로그를 저장
  • RollingFileAppender: 여러 개의 파일을 순회하면서 로그를 저장
  • SMTPAppender: 메일로 로그를 전송
  • DBAppender: 데이터베이스에 로그를 저장

Encoder

  • 로그를 사람이 읽기에 친숙한 포맷으로 변환합니다.(e.g., text, JSON)
  • Appender 내에서 로그 이벤트를 어떻게 변환할지 정의합니다.

Pattern

  • 로그 메시지의 출력 형식을 지정합니다.
  • Pattern은 Encoder 내에서 지정됩니다.
  • %date, %level, %logger, %msg과 같은 패턴을 사용하여 출력할 수 있습니다.

Root

  • Logger 레벨의 최상층을 나타냅니다.
  • Root 영역에서 Appender를 참조해서 로깅 레벨을 설정합니다.

Logback 사용 예시

logback.xml

<configuration>
  <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
    <encoder>
      <pattern>%date{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
    </encoder>
  </appender>

  <root level="INFO">
    <appender-ref ref="STDOUT"/>
  </root>
</configuration>
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class MyExample {

    private static final Logger logger = LoggerFactory.getLogger(MyExample.class);

    public static void main(String[] args) {
        logger.debug("This is a debug message");
        logger.info("This is an info message");
        logger.warn("This is a warning message");
        logger.error("This is an error message");
    }
}
2024-02-22 23:50:49.999 [main] INFO  com.example.MyExample - This is an info message
2024-02-22 23:50:49.999 [main] WARN  com.example.MyExample - This is a warning message
2024-02-22 23:50:49.999 [main] ERROR com.example.MyExample - This is an error message

스프링 부트에서 사용하기

스프링 부트의 spring-boot-starter-web 라이브러리에는 이미 Logback 프레임워크가 내장되어 있습니다.

또한 스프링 부트는 다음의 이름으로 resource 패키지 안에서 Logback 설정 파일을 찾습니다.

  • logback-spring.xml
  • logback.xml
  • logback-spring.groovy
  • logback.groovy

참고 자료

이미지 출처:&nbsp;https://miro.medium.com/v2/resize:fit:1400/1*XY-4dQf4EZuYCuG71nEOJQ.jpeg

자바에서 불변(Immutable) 객체에 대한 이해

  • 가변 객체는 상태 정보를 변경할 수 있어 불안정하고, 작업 전후에 일일이 코드를 확인하거나 DB를 호출해야한다.
  • 하지만 불변 객체는 한번 만들어지면 상태가 변경되지 않아 안정적인 개발이 가능하다.
  • 불변(Immutable)객체는 생성 이후에 상태가 변하지 않는 객체이다.
  • 불변 객체는 중복 제거의 장점을 가지며, 안전한 서비스 개발에 도움이 된다.
  • 예를 들어 DB에서 객체 정보를 받아와 작업할 때, 의도치 않게 불변 객체의 정보를 변경하려는 경우 불변 객체를 사용하면 이를 방지할 수 있다.
  • 또한 map, set, cache에 쓰기에 적절하다.
  • 불변 객체를 사용하면 Thread-safe의 장점이 있다.
  • 데이터 불일치 역시 없어, 안전하게 여러 셀에서 상태정보를 공유할 수 있다.
  • _변하지 않는 객체_이기 때문에 방어적 복사가 불필요하다는 장점도 있다.
  • 하지만 모든 것을 불변 객체로 표현할 수 없지만, 가능하다면 적극적으로 사용하는 것이 권장된다.

Java에서 String

  • Java에서 String은 불변 객체이며, 객체는 할당되는 값에 따라 새로운 객체를 생성한다.

  • 따라서 새로운 값을 할당하면 해당 값으로 기존 객체를 변경하는 것이 아니라, 새로운 객체를 생성한다.

    String str1 = "Test String1";
    String sameStr = str1;
    System.out.println(str1 == sameStr) // 같은 메모리 주소를 가르키고 있으므로 true (동일성)
    str1 = "Test String2"; // 새 객체가 만들어져 메모리 주소가 바뀜
    System.out.println(str1 == sameStr); // 서로 다른 메모리 주소에 존재하므로 false

Java에서 불변 객체 만들기

  • 자바에서 불변 객체를 만드는 방법은 생성자로만 설정해주고 setter메서드를 제공하지 않는 것이다.
  • final 키워드를 사용하여 클래스의 모든 필드를 final 지정해주면 더 이상 변경이 불가능한 것으로 설정 가능함.
  • final로 지정된 변수는 값을 한번 저장하면 변경이 불가능하기 때문에, private와 함께 사용하여 해당 변수가 차후에 바뀔 여지가 없도록 할 수 있음.

final 변수를 클래스 상속과 오버라이드로 내부적으로 _이름 바꾸기_가 가능

클래스 상속과 오버라이드로 final 변수의 수정이 가능하다.

public class Person {
  private final String name;

  public Person(String name) {
      this.name = name;
  }

  public String getName() {
      return name;
  }
}

public class NewPerson extends Person {
    private String newName;

    public NewPerson(String name) {
        super(name);
        newName = name;
}

    public void setName(String name) {
        this.newName = name;
}

    public String getName() { //override
        return this.newName;
}
Person person = new NewPerson("messi");
System.out.println(person.getName()); // messi

NewPerson newPerson = (NewPerson) person;
newPerson.setName("wow");

System.out.println(person.getName()); // wow
  • 클래스 'NewPerson'는 'Person'을 상속받아 클래스를 정의하고, 생성자의 파라미터로 이름(name)을 받을 수 있도록 하였다.
  • 'getName' 메서드를 오버라이드하고 'newName'에 저장하여 리턴한다.
  • Person 객체를 상속한 NewPerson 객체를 생성하고, 타입 캐스팅을 활용하여 'Person' 클래스의 이름을 'wow'로 바꾼 후 출력하면 바뀐 이름이 출력된다.
  • 결과적으로 Person 클래스의 name은 final 이지만, 상속받은 클래스의 setter 메서드에 의해 값이 변하였다.
  • 이를 방지하려면 'final' 키워드를 클래스 앞에 붙여서, 클래스를 상속할 수 없게하는 방법이 있다.

'final' 클래스 내부에 mutable 객체를 가리키는 레퍼런스가 있다면?

public class RGB {
  public int r;
  public int g;
  public int b;

  public RGB(int r, int g, int b){
    this.r = r;
    this.g = g;
    this.b = b;
    }
}
public class Person {
  private final String name;
  private final RGB rgb; // RGB is mutable

  public Person(String name, RGB rgb) {
      this.name = name;
    this.rgb = rgb;
  }

  public String getName() {
      return name;
  }
  public RGB getRGB() {
      return rgb;
  }
}

위 코드에서 RGB rgb를 final로 선언했더라도, 완전히 immutable하다고 볼 수 없다.

RGB green = new RGB(0, 128, 0);
Person person = new Person("messi", green);
System.out.println(person.getRGB().g); // 128
green.g = 0;
System.out.println(person.getRGB().g); // 0

위 코드를 실행하면, 결국 rgb의 값을 바꿀 수 있게 된다. 이는 왜냐하면 RGB rgb는 객체를 가르키는 레퍼런스 변수이기 때문에, 영향을 받을 수 있기 때문이다.
따라서 다음과 같이 수정을 해야 한다.

public class Person {
  private final String name;
  private final RGB rgb; // RGB is mutable

  public Person(String name, RGB rgb) {
      this.name = name;
    this.rgb = new RGB(rgb.r, rgb.g, rgb.b); // new RGB
  }

  public String getName() {
      return name;
  }
  public RGB getRGB() {
      return rgb;
  }
}

하지만 이 코드도 완벽하지 않다. 아래 코드를 실행하면 RGB rgb 객체를 수정할 수 있다.

RGB green = new RGB(0, 128, 0);
Person person = new Person("messi", green);
System.out.println(person.getRGB().g); // 128
RGB myRGB = person.getRGB();
myRGB.g = 0;
System.out.println(person.getRGB().g); // 0

getRGB가 레퍼런스 변수를 리턴하기 때문이다. 따라서 getRGB 메서드를 아래와 같이 수정해야한다.

public RGB getRGB() {
      return new RGB(rgb.b, rgb.r, rgb.b);
  }

위와 같은 수정을 방어적 복사라고 한다.

완전한 불변 객체 만들기

  • 상태 변경 메서드 제거(setter)
  • 모든 필드를 private final로 지정
  • 클래스의 상속을 금지하기
  • mutable 객체 레퍼런스를 공유하지 않기
  • 기존 값 그대로 리턴하지 말고, _새로운 객체를 생성하여 값을 새 객체의 필드에 할당_해야 함 -> 방어적 복사
  • 아에 레퍼런스 객체 또한 immutable로 만든다면 방어적 복사를 할 필요가 없다.

리스트(Set, Array ...)객체 형을 필드로 가질 때 방어적 복사를 하는 방법.

  • 리스트는 값이 아니라 _주소 정보_를 가진다.
  • 방어적 복사를 하려면, _원본 객체를 변경하지 않고 새 객체를 생성 후 새로운 객체에 값을 복사_한다.
  • 따라서 리스트를 방어적 복사를 하기 위해서는 _새로운 리스트를 만든 후, 새로운 객체를 복사하여 그 새로운 객체를 새 리스트에 추가_한 후, 최종적으로 새로운 리스트를 리턴해야 한다.
public class Person {
    private final String name;
    private final List<RGB> rgbs;// RGB is mutable

    public Person(String name, List<RGB> rgbs) {
        this.name = name;
        this.rgbs = copy(rgbs);
    }

    public String getName() {
        return name;
    }

    public RGB getRGBs() {
        return copy(rgbs);
    }

    private List<RGB> copy(List<RGB> rgbs) {
        List<RGB> cps = new ArrayList<RGB>();
        rgbs.forEach(o -> cps.add(new RGB(o.r, o.g, o.b)));
        return cps;
    }
}

얕은 복사와 깊은 복사

얕은 복사(Shallow Copy):

int[] originalArray = {1, 2, 3};
int[] shallowCopy = Arrays.copyOf(originalArray, originalArray.length);

originalArray[0] = 10;

System.out.println("Original array: " + Arrays.toString(originalArray));
System.out.println("Shallow copy: " + Arrays.toString(shallowCopy));
Original array: [10, 2, 3]
Shallow copy: [10, 2, 3]
  • Arrays.copyOf는 얕은 복사를 수행한다. 또한 new ArrayList<>(originalArray);도 얕은 복사이다.
  • 얕은 복사는 원래 객체를 참조하는 객체를 만든다.
  • 따라서 참조하는 객체의 변경이 이루어지면, 얕은 복사를 한 객체도 영향을 받는다.

깊은 복사(Deep Copy)

class Point {
    int x, y;

    public Point(int x, int y) {
        this.x = x;
        this.y = y;
    }
}

public class Example {
    public static void main(String[] args) {
        Point originalPoint = new Point(5, 7);
        Point deepCopy = new Point(originalPoint.x, originalPoint.y); // Deep copy

        originalPoint.x = 10;

        System.out.println("Original point: (" + originalPoint.x + ", " + originalPoint.y + ")");
        System.out.println("Deep copy: (" + deepCopy.x + ", " + deepCopy.y + ")");
    }
}
Original point: (10, 7)
Deep copy: (5, 7)
  • 깊은 복사는 복사하려는 객체와 똑같은 데이터를 가지고 새로운 객체를 생성하는 것이다.
  • 새로운 객체를 만들었기 때문에 메모리 주소가 다르고, 참조하는 객체의 수정에도 영향을 받지 않는다.

불변 객체 의미 차이?

  • 외부에 노출되는 상태 정보는 immutable 하지만, 내부에서만 관리되는 상태는 mutable한 경우에도 immutable 객체라고 부르기도 한다. 이 때 thread-safe하지 않을 수 있다. 왜냐하면 여러 thread들이 상태를 바꾸다 보면 race-condition이 발생할 수 있기 때문이다.
  • 파이썬에서 불변 객체는 'immutable container가 mutable 객체를 가지고 있을 때 mutable 객체의 값이 바뀌어도 이 immutable container는 immputable이라고 여겨진다'라고 명시되어 있다.
  • java에 불변 객체의 개념보다 훨씬 더 열려있는 개념이며, 파이썬의 튜플(tuple)은 느슨한 개념을 대표하는 예시다.

참고