반응형

클라이언트가 보낸 정보를 매핑한 Request 객체(DTO).

이 객체는 @Valid 애너테이션을 비롯해 간단하게 검증할 수 있는 방법이 많습니다.

 

그런데 Reqeust의 검증 책임은 어디까지일까요?

 


 

1) Request는 비즈니스 로직 외 간단한 검증만 한다.

Layered architecture를 기준으로, Request의 위치는 Presentation layer입니다.

더 자세하게는 Client(View)와 Controller 사이에 위치합니다.

Layered architecture는 계층 별로 관심사를 분리해 유지보수와 테스트를 편리하게 만드는 것이 핵심입니다.

이중 Presentation layer의 관심사는 필요한 데이터를 클라이언트에게 전달하거나, 클라이언트가 보낸 요청을 서버 내부로 가져오는 것입니다.

따라서 Request는 어떠한 비즈니스 로직도 적용하지 않고, 요청이 부적절하지는 않은지만 검증하는 것이 가장 상식적인 판단일 것입니다. (null 가능성 체크 등)

 

하지만 이 또한 Layered architecture를 만족하는 선에서의 정답일 뿐입니다.

어떤 상황에서는 맹점이 존재할 수도 있습니다.

 

 

2) Request가 도메인 검증 책임을 갖는다.

유연한 검증 책임

Image URL에 규칙을 추가하는 예시를 하나 들어보겠습니다.

class Product {
	private final String imageUrl;
	...
}

도메인 객체인 Product가 imageUrl이라는 필드를 갖는데,

해당 필드의 앞에 반드시 https 가 붙어야된다는 규칙이 추가되었다고 가정해보겠습니다.

Domain 객체의 검증 로직에 해당 내용을 추가한다면,

그 전의 기준이 적용된 DB의 데이터를 꺼내와 Domain 객체를 생성할 수가 없게 됩니다.

imageUrl에 https가 붙어야 한다는 것은 Domain 로직이 확실한데 말이죠.

 

이것을 해결하기 위해 아래와 같은 복잡한 해결책을 택해야 할 수도 있습니다.

  • 데이터베이스 수정 (마이그레이션 or 필드 추가)
  • 새로운 Domain 객체 생성

 

반면에, Request에 검증 책임을 추가한다면 비교적 쉽게 문제를 해결할 수 있습니다.

또, 검증 기준이 바뀌는 경우에도 쉽게 대응할 수 있습니다.

Request(DTO)만을 검증하는 Validator 객체를 만들 수도 있고,

Product를 검증하는 ProductValidator를 상속받거나 구현한 객체를 만들 수도 있습니다.

 

 

철저한 검증

안전한 데이터가 그 어떤 것보다 중요한 환경일 때 (금융 등의 도메인)

요청이 서버에 들어와서 응답을 반환하기까지,

서버 내부에 안전한 데이터만 유지시키고 싶을 수 있습니다.

 

Request에서 Web 환경만을 고려한 데이터를 받는다면,

Domain 객체가 생기기 전까지 Presentation ~ Business layer에 걸쳐

올바르지 않은 데이터가 존재할 수 있습니다.

이것이 잘못 사용되어 Domain에서의 검증을 거치지 않고 DB에 저장되는 등의 행위가 일어난다면,

운영 중인 서비스에 문제가 발생할 수 있습니다.

 

이런 경우, Request에서부터 Domain 기준으로 검증을 한다면

웹 요청 ~ 반환 전체 cycle에 대해 안전한 데이터만 존재하도록 만들 수 있습니다.

또한, 특정 요청에 대해 특수한 검증 로직이 적용되어야 한다면

Request에서 간단하게 수행할 수도 있습니다.

 

하지만 중복된 검증 로직을 별도로 작성하는 것은 유지보수(동기화)가 너무 어려울 수 있습니다.

이때, Domain에서 사용하는 Validator 객체를 사용하거나, 그것을 구현, 상속한 객체를 사용함으로써

관리를 일원화할 수 있습니다.

또한 테스트도 용이해집니다.

 

 

3. Domain과 Request의 검증 책임 정리

제 생각을 토대로 책임을 정리해보면 아래와 같습니다.

Domain 필수

  • Domain 객체로서 변하지 않을 성질
    • ex: Product의 price는 0 이상이어야 한다.
  • 논리를 통한 검증
    • ex: 로또 번호는 서로 같을 수 없다.
  • 저장된 정보와의 비교
    • ex: DB에 저장된 ID와의 중복 비교

 

Request 필수

  • 요청에 대한 올바른 정보인지
    • null 체크
    • 형식 비교 (전화번호, 이메일 등)

 

Request의 검증 로직 확대

  • 가능한 한 Domain에서 해결한다.
    • 10,000원 이하의 핫딜 상품 등록 → Request에서 검증하기 보다, Product를 구현한 HotDealProduct 생성 등
    • imageUrl에 https 필수 → DB 수정을 통해 해결 등
  • 추가될 검증 로직이 요청에 종속되고 변경 가능성이 높은 경우, Request에서 해결할 수 있다. (절충)
    • 특히 Domain의 검증 로직이 변경되기 매우 어려운 경우
  • 서버 내부에 안전한 데이터만 두고 싶을 때
    • 중복된 검증 로직을 동기화할 수 있는 방안이 마련되어야 함 (테스트 등)

 

 


 

결국 문제를 해결하기 위한 여러가지 방안을 떠올리고,

지금과 미래를 고려하여 최적의 방안을 선택하는 것이 중요하다는 생각이 듭니다.

 

의견과 지적을 환영합니다.

감사합니다!

 

 

 

 

 

 
반응형
반응형

서론

RestAssured를 통한 Spring 테스트 구현 시,

반환되는 HTML 내부에 특정 데이터가 들어있는지 확인할 필요가 생겼습니다.

 

SSR(Server-Side Rendering) 방식으로 Controller에서 HTML로 쓰여진 View를 반환하는 경우,

데이터가 HTML 내부로 렌더링된 후 반환됩니다.

따라서 동작을 제대로 검증하려면, HTML 내부에 의도한 값이 들어있는지를 검증할 필요가 있습니다.

 

 


 

RestAssured로 HTML 검증하기

 

HTML 꺼내기

우선 원하는 응답을 저장해야 합니다.

 

방법 1)

Response response = get("/");

 

방법 2)

ExtractableResponse<Response> extractableResponse = get("/")
                .then().log().all()
                .extract();

Response는 기본적인 형태의 응답을 저장합니다.

ExtractableResponseextract() 메서드로 반환한 결과를 담고 있습니다.

JSON 형식으로 추출하는 등의 추가 기능이 가능합니다.

 

get() 메서드 이후 체이닝으로 log()를 붙이는 경우, Response로 반환이 되지 않아

extract()를 통해 ExtractableResponse로 응답을 저장해보겠습니다.

 

String xmlString = response.asString();
XmlPath xmlPath = new XmlPath(CompatibilityMode.HTML, xmlString);

HTML 정보가 담긴 XmlPath 인스턴스가 생성되었습니다.

 

HTML의 원하는 정보 꺼내기 1

위에서 get(”/”) 요청에 대해 log()를 호출했는데요,

그 결과는 아래와 같습니다.

<html lang="en">
  <head>
    <meta charset="UTF-8"/>
    <title>상품목록</title>
    <link href="https://fonts.googleapis.com/css?family=Open+Sans:300,400,600,700&amp;display=swap" rel="stylesheet"/>
    <link rel="stylesheet" href="/css/base.css"/>
    <link rel="stylesheet" href="/css/styles.css"/>
  </head>
  <body>
    <header class="gnb">
      <nav>
        <ul class="gnb-group">
          <li>
            <a shape="rect" href="/">상품목록</a>
          </li>
          <li>
            <a shape="rect" href="/cart">장바구니</a>
          </li>
          <li>
            <a shape="rect" href="/settings">설정</a>
          </li>
          <li class="nav-admin">
            <a shape="rect" href="/admin">관리자</a>
          </li>
        </ul>
      </nav>
    </header>
    <div class="container">
      <ul class="product-grid">
        <li class="product">
          <div class="product-image">
            <img alt="상품 이미지" src="super.com"/>
          </div>
          <div class="product-info">
            <div class="product-desc">
              <p class="product-name">사과즙</p>
              <p class="product-price">1000</p>
            </div>
            <button type="submit" class="product-btn" onclick="addCartItem(1)">담기</button>
          </div>
        </li>
        <li class="product">
          <div class="product-image">
            <img alt="상품 이미지" src="super.com"/>
          </div>
          <div class="product-info">
            <div class="product-desc">
              <p class="product-name">포도</p>
              <p class="product-price">2000</p>
            </div>
            <button type="submit" class="product-btn" onclick="addCartItem(3)">담기</button>
          </div>
        </li>
      </ul>
    </div>
    <script src="https://unpkg.com/axios/dist/axios.min.js"/>
    <script src="/js/cart.js"/>
  </body>
</html>

위의 HTML에서 포도2000이라는 값을 꺼내보겠습니다.

 

System.out.println(xmlPath2.getString(
                "html.body.div" // 1번 문장
                        + ".ul.find { it.@class == 'product-grid' }"
                        + ".li[1]" // 2번 문장
                        + ".div.find { it.@class == 'product-info' }" // 3번 문장
                        + ".div.find { it.@class == 'product-desc' }" // 4번 문장
                        + ".p.find { it.@class == 'product-name' }"));
// "포도" 출력

1번 문장) 가장 상위부터 원하는 값이 있는 곳까지 블럭을 꺼내면 됩니다.

html.body.div.ul ...

  

1번 문장) 해당 위치에 같은 블럭이 한 개 있는 경우 바로 꺼낼 수 있습니다. (여러 개라면 0번째가 꺼내집니다.)

.div.find { it.@class == 'container' }

2번 문장) 해당 위치에 같은 블럭이 여러 개 있는데, 이름이 모두 같은 경우 인덱싱으로 꺼낼 수 있습니다.

.li[1]

3, 4번 문장) 해당 위치에 같은 블럭(div)이 여러 개 있지만 이름이 다른 경우 → 이름으로 꺼낼 수 있습니다.

.div.find { it.@class == 'product-info' }

.div.find { it.@class == 'product-desc' }

포도의 가격에 해당하는 2000은 아래와 같이 꺼낼 수 있습니다.

System.out.println(xmlPath2.getString(
                "html.body.div"
                        + ".ul.find { it.@class == 'product-grid' }"
                        + ".li[1]"
                        + ".div.find { it.@class == 'product-info' }"
                        + ".div.find { it.@class == 'product-desc' }"
                        + ".p.find { it.@class == 'product-price' }")); // 여기만 수정
// "2000" 출력

 

HTML의 원하는 정보 꺼내기 2 - table 정보

이번에는 아래처럼 table 정보가 있는 HTML에서 원하는 값을 찾아보겠습니다.

원리는 동일합니다.

<html lang="en">
  <head>
    <meta charset="UTF-8"/>
    <title>관리자 페이지</title>
    <link href="https://fonts.googleapis.com/css?family=Open+Sans:300,400,600,700&amp;display=swap" rel="stylesheet"/>
    <link rel="stylesheet" href="/css/base.css"/>
    <link rel="stylesheet" href="/css/admin.css"/>
  </head>
  <body>
    <header class="gnb">
      <nav>
        <ul class="gnb-group">
          <li>
            <a shape="rect" href="/">상품목록</a>
          </li>
          <li>
            <a shape="rect" href="/cart">장바구니</a>
          </li>
          <li>
            <a shape="rect" href="/settings">설정</a>
          </li>
          <li class="nav-admin">
            <a shape="rect" href="/admin">관리자</a>
          </li>
        </ul>
      </nav>
    </header>
    <div class="container">
      <div class="btn-group">
        <button type="submit" class="add-btn" onclick="showAddModal()">상품 추가</button>
      </div>
      <table>
        <tr>
          <th colspan="1" rowspan="1">ID</th>
          <th colspan="1" rowspan="1">이름</th>
          <th colspan="1" rowspan="1">가격</th>
          <th colspan="1" rowspan="1">이미지</th>
          <th colspan="1" rowspan="1">Actions</th>
        </tr>
        <tbody id="product-list">
          <tr>
            <td colspan="1" rowspan="1">1</td>
            <td colspan="1" rowspan="1">사과즙</td>
            <td colspan="1" rowspan="1">1000</td>
            <td colspan="1" rowspan="1">
              <img style="max-width: 100px;" src="super.com"/>
            </td>
            <td colspan="1" rowspan="1">
              <button type="submit" onclick="showEditModal({&quot;id&quot;:1,&quot;name&quot;:&quot;\uC0AC\uACFC\uC999&quot;,&quot;price&quot;:1000,&quot;imageUrl&quot;:&quot;super.com&quot;})">수정</button>
              <button type="submit" onclick="deleteProduct(1)">삭제</button>
            </td>
          </tr>
          <tr>
            <td colspan="1" rowspan="1">3</td>
            <td colspan="1" rowspan="1">포도</td>
            <td colspan="1" rowspan="1">2000</td>
            <td colspan="1" rowspan="1">
              <img style="max-width: 100px;" src="super.com"/>
            </td>
            <td colspan="1" rowspan="1">
              <button type="submit" onclick="showEditModal({&quot;id&quot;:3,&quot;name&quot;:&quot;\uD3EC\uB3C4&quot;,&quot;price&quot;:2000,&quot;imageUrl&quot;:&quot;super.com&quot;})">수정</button>
              <button type="submit" onclick="deleteProduct(3)">삭제</button>
            </td>
          </tr>
        </tbody>
      </table>
      <div class="modal" id="modal" data-form-type="add">
        <div class="modal-content">
          <span class="close" onclick="hideAddModal()">×</span>
          <form enctype="application/x-www-form-urlencoded" method="get" id="form">
            <label for="name">상품명</label>
            <br clear="none"/>
            <input type="text" id="name" name="name" required="required"/>
            <br clear="none"/>
            <label for="price">가격</label>
            <br clear="none"/>
            <input type="number" id="price" name="price" required="required"/>
            <br clear="none"/>
            <label for="image-url">이미지 URL</label>
            <br clear="none"/>
            <input type="text" id="image-url" name="imageUrl" required="required"/>
            <br clear="none"/>
            <button type="submit">제출</button>
          </form>
        </div>
      </div>
    </div>
    <script src="https://unpkg.com/axios/dist/axios.min.js"/>
    <script src="/js/admin.js"/>
  </body>
</html>

위의 HTML 응답에서, 마찬가지로 포도 값을 꺼내보겠습니다.

 

System.out.println(xmlPath.getString("html.body.div.table.tbody.tr[1].td[1]"));
// "포도" 출력

이름으로 구분할 필요가 없고, 순서가 명확해서 인덱싱으로만 값을 꺼낼 수 있었습니다.

 

이렇게 꺼낸 값들을, 아래와 같이 assertion을 통해 테스트할 수 있습니다.

assertAll(
        () -> assertThat(
                xmlPath.getString("html.body.div.table.tbody.tr[0].td[1]")
                ).contains("사과즙"),
        () -> assertThat(
                xmlPath.getString("html.body.div.table.tbody.tr[0].td[2]")
                ).contains("1000"),
        () -> assertThat(
                xmlPath.getString("html.body.div.table.tbody.tr[1].td[1]")
                ).contains("포도")
);

 

 

View에 지나치게 의존적이다

위의 테스트 방식은,

화면(HTML)의 구조가 바뀌는 순간 테스트가 실패할 수 있습니다.

그리고 그 수정이 무척 까다롭습니다.

 

따라서, 적당히 절충하여 아래와 같이 테스트할 수 있습니다.

// 응답 추출
String responseString extractableResponse = get("/")
                .then().log().all()
                .extract()
                                .asString();

assertThat(responseString).contains("포도");

중복되지 않는 데이터를 테스트한다면,

위와 같은 테스트만으로 동작이 제대로 이루어졌는지 판단할 수 있을 것입니다.

 

물론 정확한 위치까지 테스트하는 것이 필요할 경우도 있겠습니다.

 


 

더 좋은 방식이 있다면 추천해주세요!

감사합니다.

 

 

참고자료

 

 

 

 

반응형
반응형

 

데이터베이스에 대해 CRUD를 수행하고,
그 결과로 무엇을 반환해야 할까요?

 

결론부터 말하면 정답은 없다 입니다.
너무나도 열려 있는 문제이기에, 검색을 통해서도 전반적인 경향을 파악하기도 어렵습니다.

 

하지만 상황에 따라 더 나은 해답은 있을 수 있기에,
그것에 대해 개인적으로 고민한 결과를 공유해보고자 합니다.

 


 

개요

데이터베이스에 접근하는 DAO(Data Access Object)가 구현되어 있고,
다음의 3가지 반환타입을 가질 수 있다고 가정하겠습니다.

  • 데이터베이스 테이블에 1:1로 매핑되는 Entity
  • Entityid
  • void
    이외에도 작업이 성공했음을 나타내는 boolean, 영향을 준 row의 개수를 나타내는 값 등을 가질 수도 있지만,
    문제를 단순화하기 위해 고려하지 않겠습니다. (그것이 필요한 정보라면 반환해야할 것입니다.)

반환 타입이 고민되는 가장 큰 지점은 어떤 정보가 필요한가?이고,
그중에서도 변경된 정보가 필요한가? 입니다.

 

예를 들면, 어떤 정보를 수정했을 때
수정한 정보를 다시 반환해줄 필요가 있는지에 대한 문제입니다.

 

이것을 기준으로 CRUD 각각에 대한 견해를 작성해보겠습니다.

 

 

Read

  • Entity / List<Entity> 반환

Read(SQL의 SELECT)의 경우는 딱히 고민의 여지가 없습니다.

조회한 정보를 Entity(혹은 Entity의 컬렉션, 예를 들면 List<Entity>)로 반환하면 됩니다.

 

Delete

  • void 반환
  • idEntity도 가능

Delete(SQL의 DELETE)는 대개 아무 정보를 반환하지 않습니다.

데이터가 삭제되었기 때문에, 다시 활용되지 않는 것이 자연스럽다고 생각합니다.

하지만 삭제한 정보가 필요한 경우에는, id가 아닌Entity를 반환할 필요가 있습니다.

 

예를 들어 id를 입력 받아, 해당하는 정보를 삭제하는 경우를 생각해보겠습니다.

삭제한 정보를 사용자에게 보여줘야 한다면, id만으로는 다시 정보를 조회할 수 없습니다. 정보가 DB에서 삭제되었기 때문입니다.

따라서 삭제한 정보가 필요한 경우에는 Entity를 반환해야 합니다.

 

Create

  • id / Entity 반환
  • void 불가능

Create(SQL의 INSERT)는 void를 반환해서는 안 됩니다.

왜냐하면, void를 반환하고 생성한 정보가 사용자에게 필요한 경우가 생긴다면

다시 찾을 수가 없기 때문입니다.

 

(DB에서 id 부여한다고 가정하면) 생성 당시에는 id가 없었을 것이기에,

Create를 호출한 곳에는 id를 알지 못합니다.

따라서 Create는 생성한 정보(DB 테이블의 Row)의 id를 반환해야 합니다.

 

필요한 경우에는 Entity를 반환할 수도 있습니다.

이 경우, 생성한 정보의 id를 가지고 SELECT 쿼리를 한 번 더 실행해야 합니다.

 

Create하는 많은 경우가, 생성한 정보를 즉시 필요로 한다면

INSERTSELECT를 다시 실행하더라도 Entity를 반환하는 것이 유리할 수 있습니다.

(id를 통한 단건 조회라면, 데이터베이스의 인덱싱에 의해 속도가 굉장히 빠릅니다.)

 

Update

  • id / Entity / void 가능

Update는 모든 경우가 가능합니다.

 

우선 void를 반환해도 무방합니다.

id를 통해 정보를 업데이트한다면, 필요할 때 id로 해당 정보를 다시 조회할 수 있기 때문입니다.

(id가 아니라 특정 조건이라고 해도 마찬가지입니다.)

 

Create와 마찬가지로, 필요한 경우에 idEntity를 반환할 수 있습니다.

 

 

보다 나은 방식을 위해 반드시 고려할 사항

위에서는 CRUD에서 가능한 반환타입에 대해 고민해보았는데요,

그럼에도 CRUD 각각의 반환타입을 정하는 것은 쉽지 않습니다.

 

하지만 아래의 고려 사항이 좋은 힌트가 될 수 있습니다.

통일성

Createid를 반환하는데 Updatevoid 또는 Entity를 반환하는 경우,

DAO를 사용하는 사람(다른 개발자를 가정) 입장에서 무척 헷갈릴 수 있을 것 같습니다.

 

이것이 팀 내부에서 사용되는 단순 클래스가 아닌 오픈소스 라이브러리의 클래스라면,

사용하는 사람 입장에서 더욱 헷갈릴 것입니다. 그렇다고 물어보기도 쉽지 않습니다.

최소 놀람의 법칙을 고려하여, 통일성을 고민하는 것이 필요합니다.

 

Createid를 반환한다면, Updateid를 반환하고,

CreateEntity를 반환한다면, UpdateEntity를 반환하는 것이

보다 직관적인 구조라고 생각합니다.

 

비즈니스적 관점

해당 프로그램이 해결하는 비즈니스 문제의 특성을 고려해야 합니다.

만약 해당 비즈니스에서 정보의 삽입, 수정이 일어날 때마다 즉시 사용자에게 정보를 보여줘야 한다면,

그때마다 Entity를 반환하는 것이 좋을 수도 있습니다.

 

코드로 예시를 들어보겠습니다.

// Create가 id만 반환하는 경우
class OrderService {
        ...

        public Order createOrder(int price) {
                long id = this.orderDao.create(price);
                OrderEntity = this.orderDao.findById(id);
                ... // Order 생성
                return order;
        }

                public Order createFreeOrder() {
                long id = this.orderDao.create(0);
                OrderEntity = this.orderDao.findById(id);
                ... // Order 생성
                return order;
        }
}

위처럼 Service에서 DAOCreate, Update를 호출하는 메서드의 대부분이 Entity를 필요로 한다면,

굳이 DAO의 메서드를 2번 호출하기보다 create() 메서드가 OrderEntity를 반환하는 것이 나을 수 있습니다.

 

// Create가 Entity를 반환하는 경우
class OrderService {
        ...

        public Order createOrder(int price) {
                OrderEntity = this.orderDao.create(price);
                ... // Order 생성
                return order;
        }

                public Order createFreeOrder() {
                OrderEntity = this.orderDao.create(0);
                ... // Order 생성
                return order;
        }
}

(보수 받는 개발자 경력이 없는지라, 가정이 많이 부족할 수 있습니다.)

 

그런데 CREATE 또는 UPDATE한 정보를 다시 반환하는 것이, 비용이 많이 드는 일일 수도 있습니다.

 

아래에 의사코드로 예시를 들겠습니다.

class OrderDao {
        ...

        public List<OrderEntity> updateIfPriceOverThousand(Data data) {
                database.update("UPDATE order SET price data = data WHERE price > 1000");
                List<OrderEntity> orderEntities = database.query("SELECT * FROM order WHERE price > 1000");
                return orderEntities;
        }
}

위의 코드에서는, DAO에서 UpdateEntityList를 반환합니다.

그런데, price가 1,000 이상인 데이터가 수백만 건이라면,

게다가 해당 정보가 반드시 필요한 것이 아니라면,

필요하지 않은 경우 SELECT를 실행하지 않는 것만으로 서버 리소스가 크게 감소할 수 있을 것입니다.

(더 나은 비즈니스적 가정이 있다면 추천 부탁드립니다!)

 


 

역시나 정답은 없는 것 같습니다.

하지만 그때그때의 필요만 고민하기보다는

지금의 코드가 쌓여 나중에 어떤 코드가 될지 고려하면서 구현한다면,

미래의 나와 동료 개발자들이 고민할 시간을 아껴줄 수 있다는 생각이 듭니다.

 

지적과 의견을 환영합니다.

감사합니다!

 

 

 

 

 

 

반응형
반응형

서버에서는 요청에 대한 반환만 전달하고, 클라이언트가 화면을 렌더링하는 CSR(Client-Side Rendering) 방식에서는,
Controller가 기능(또는 도메인) 기준으로 분리되는 것이 일반적입니다.

 

그런데 서버에서 요청에 대한 수행 뿐만 아니라, 데이터를 렌더링한 화면을 반환하는 SSR(Server-Side Rendering) 방식에서,
Controller는 어떻게 분리되어야 할지 고민이 되었습니다.

 

화면 단위로 구분해야 하는 것이 맞을지, 기능(또는 도메인 / 이하 도메인)단위로 구분해야 하는 것이 맞을지,
고민의 대한 개인적인 결론을 공유드려봅니다.

 

먼저, 각각의 방식에 대한 예시와 특징을 살펴보고,
마지막으로 각각을 비교하면서 나름의 결론을 내려보겠습니다.

 


화면 단위로 구분하는 예시

현재 웹 사이트가 domain.com/domain.com/admin의 2가지 화면을 가지고 있다고 가정하겠습니다.
아래는 Spring 프레임워크로 구현된 Controller의 의사코드입니다.

// domain.com/ 에서 사용되는 기능을 모아둔 Controller

@Controller
public class HomeController {

    @GetMapping
    public String productList(final Model model) {
        List<Product> products = productDao.findAll();
        model.addAttribute("products", products);
        return "index";
    }
}
// domain.com/admin 에서 사용되는 기능을 모아둔 Controller

@RequestMapping("/admin")
@Controller
public class AdminController {

@PostMapping("/products")
    public void saveProduct(@RequestBody Request request) {
        // (프로덕트 저장 로직)
// 이후 Javascript 파일에서 이전 뷰를 reload함
    }

    @GetMapping
    public String getAllProducts(final Model model) {
        List<Product> products = productService.findAll();
        model.addAttribute("products", products);  // 상품 정보 렌더링 로직
        return "admin";
    }
...
}

화면에 필요한 기능들이 모아져 있습니다.

 

도메인 단위로 구분하는 예시

// Product 관련 기능을 모아둔 Controller

@RequestMapping("/products")
@Controller
public class ProductController {

@PostMapping
public void saveProduct(@RequestBody Request request) {
        // (프로덕트 저장 로직)
// 이후 Javascript 파일에서 이전 뷰를 reload함
    }
...
}

이 경우, 아래와 같이 뷰를 반환하는 Controller가 별도로 존재해야 합니다.
(아니면 기능 단위로 뷰를 분리할 수도 있겠으나 개인적으로는 구조가 직관적이지 않다고 생각합니다.)

// View를 반환하는 Controller
@Controller
public class ViewController {

@GetMapping
public String getHomeView() {
// (렌더링 로직)
return "index";
}

@GetMapping("/admin")
public void getAdminView() {
// (렌더링 로직)
return "admin";
}
}

 

두 가지 방식 비교

Controller의 구조가 화면에 의존하느냐, 도메인에 의존하느냐,
두 가지 관점에서 고려해보겠습니다.

1. 화면과 도메인 중 어느 것이 자주 바뀔까

일반적으로는 화면이 더 자주 바뀔 것입니다.
사용자에게 보여지고, 사용자와 직접 Interaction하는 화면은
사용자의 편의를 직접적으로 고려하고 반영해야 할 일이 많을 것입니다.

 

하지만 도메인의 변경은 자주 일어나서는 안 되는 일입니다.
OCP(Open-Close Principle / 개방-폐쇄 원칙)에 의하면,
객체지향적 설계를 위해 도메인 구조는 변경에는 닫혀 있고, 확장에만 열려 있어야 합니다.

 

따라서 구조의 변경이 자주 일어날 것으로 예상되는 화면보다는,
도메인을 기준으로 Controller 구조를 설계하는 것이 맞다고 생각합니다.
변경 가능성이 적어 안정적인 구조를 위해서 그렇습니다.

2. Controller는 Service에 의존한다.

Service는 아마 기능 단위로 분리되어 있을 것입니다. (도메인 주도 개발의 관점에서 유즈케이스를 고려할 수 있으나, 이 또한 기능 중심의 분리입니다.)
만약 Controller가 화면의 구조에 맞게 설계된다면,
의존하는 Service가 많아질 수 있습니다.

 

예를 들면,
ProductService, UserService, OrderService가 존재할 때,
도메인 기준으로 설계된 ProductControllerProductService에만 의존하겠지만, (물론 의존성이 추가될 수 있습니다.)
화면 기준으로 설계된 AdminControllerProductService, UserService, OrderService를 모두 의존할 수 있습니다.
Admin 화면에서 상품, 유저, 주문 정보에 모두 접근할 수 있기 때문입니다.
따라서 도메인 기준으로 설계된 Controller의 의존 관계가 화면 기준으로 설계된 Controller보다 간결할 가능성이 높습니다.

 

위의 두 가지 이유를 종합하여,
화면 기준이 아닌 도메인(기능) 기준으로 Controller를 설계해야 한다고 결론을 내렸습니다.

 

 


 

물론 정답이 있는 문제는 아니라고 생각합니다. (진리)
사용자가 접근할 URL이 너무나도 중요해서 절대 확장하지 않고 2가지 링크에 대응되는 화면만 사용한다면,
화면이 도메인보다 안정적인 구조일 수도 있습니다.
설계에 따라서는 도메인 중심으로 나눈 ControllerService에 대해 복잡한 의존 관계를 지닐 수 있습니다.

 

그렇기에 무작정 설계를 하기 보다는,
지금의 설계가 추후의 비즈니스적 문제에 어떤 영향을 미칠 것인지를 고민하는 것이 중요하다고 생각합니다.

 

 

개인적인 생각인지라 오류가 있을지 모릅니다.
지적이나, 다른 의견 공유를 환영합니다.

 

감사합니다.

반응형
반응형

 

 

 

@Transactional 애너테이션은 메서드(혹은 클래스, 인터페이스 등)를 트랜잭션 단위로 만듭니다.

여기서 트랜잭션이란, 데이터베이스에서 수행하는 작업의 단위로, 아래와 같은 4가지 특성을 가집니다.

  • 원자성 : 하나의 작업 단위이다. (모두 수행되던가, 전혀 수행되지 않던가)
  • 일관성 : 작업 처리 결과에 일관성이 있다. (트랜잭션 진행되는 동안에 발생하는 DB 변경은 무시한다.)
  • 독립성 : 둘 이상의 트랜잭션이 동시에 실행될 때, 다른 트랜잭션의 연산에 끼어들지 않는다.
  • 지속성 : 트랜잭션이 성공적으로 완료되면, 결과는 영구적으로 반영되어야 한다.

본 아티클에서는 간단하게 원자성을 기준으로 @Transactional 애너테이션을 사용해보겠습니다.

그리고 @Transactional의 적용 범위(하위 메서드, 상위 메서드)에 대해, Spring 내부의 동작 원리를 통해 간단하게 알아보겠습니다.

 

 

트랜잭션이 아닌 경우

아래의 간단한 예시 코드를 먼저 이해해야 합니다.

(Java의 문법과 Spring의 라이브러리, 그리고 약간의 의사코드를 포함하여 작성되었습니다.)

// Dao를 사용하는 Service
public class Service {
    privata final Dao dao;

    public saveResult(int userId) {
        this.dao.saveNameById("name", userId);
        this.dao.saveAgeById(40, userId);
    }
}
// Service가 사용하는 Dao
public class Dao {

    public void saveNameById(String name, int userId) {
        // DB에서 id가 userId인 user에 입력 받은 name을 저장하는 의사코드
        this.database.executeQuery("UPDATE user SET name = ? WHERE id = ?", name);
    }

    public void saveAgeById(int age, int userId) {
        // DB에서 id가 userId인 user에 입력 받은 age를 저장하는 의사코드
        this.database.executeQuery("UPDATE user SET age = ? WHERE id = ?", age);
    }
}

ServicesaveResult()DaosaveNameById()saveAgeById()라는 2개의 메서드를 호출합니다.

그를 통해 2개의 SQL문이 실행되고, DB에는 2번의 변경이 일어납니다.

 

이때, 아래와 같이 age를 저장하던 도중 예외가 발생한다고 가정하겠습니다.

public void saveAge(int age) {
    throw new RuntimeException("저장 실패");
    this.database.executeQuery("UPDATE user SET age = ? WHERE id = 1", age) // 입력 받은 age를 DB에 저장ㅇ하는 의사코드
}

이런 상황에서 Servicesave() 메서드가 호출된다면,

DB에서 해당 username까지만 저장되고, age는 저장되지 않을 것입니다.

 

예외가 발생한다면 대개의 경우,

name만 저장되는 상황보다는 nameage가 모두 저장되지 않는 상황이 바람직할 것입니다.

@Transactional 애너테이션으로 그것을 구현해보겠습니다.

 

 

트랜잭션을 사용하는 경우

public class Service {
    privata final Dao dao;

    @Transactional
    public saveResult(int userId) {
        this.dao.saveNameById("name", userId);
        this.dao.saveAgeById(40, userId);
    }
}

하나의 단위로 묶이기 원하는 메서드나 @Transactional 애너테이션을 붙입니다.

그럼 해당 메서드가 호출되어 수행이 완료되기 까지 발생하는 DB와의 모든 상호작용이 하나의 단위가 됩니다.

위에서 작성한 코드의 예를 들면, nameage를 저장하는 작업이 하나로 묶여서,

모두 성공하면 반영(commit)되고, 하나라도 실패하거나 중간에 문제가 발생하면 회수(rollback)됩니다.

 

 

nameage를 저장하는 Dao의 메서드 각각을 트랜잭션 단위로 만들고 싶다면,

아래와 같이 사용할 수 있습니다.

public class Dao {

    @Transactional
    public void saveNameById(String name, int userId) {
        // DB에서 id가 userId인 user에 입력 받은 name을 저장하는 의사코드
        this.database.executeQuery("UPDATE user SET name = ? WHERE id = ?", name);
    }

    @Transactional
    public void saveAgeById(int age, int userId) {
        // DB에서 id가 userId인 user에 입력 받은 age를 저장하는 의사코드
        this.database.executeQuery("UPDATE user SET age = ? WHERE id = ?", age);
    }
}

 

클래스나 인터페이스의 모든 메서드에 @Transactional을 붙여야 한다면,

아래와 같이 클래스나 인터페이스 상단에 1번 붙이는 것으로 대체할 수 있습니다.

@Transactional
public class Dao {

    public void saveNameById(String name, int userId) {
        // DB에서 id가 userId인 user에 입력 받은 name을 저장하는 의사코드
        this.database.executeQuery("UPDATE user SET name = ? WHERE id = ?", name);
    }

    public void saveAgeById(int age, int userId) {
        // DB에서 id가 userId인 user에 입력 받은 age를 저장하는 의사코드
        this.database.executeQuery("UPDATE user SET age = ? WHERE id = ?", age);
    }
}

 

스프링 공식 문서에서는, 인터페이스에 주석을 추가하기보다, 구체적인 클래스나 메서드에 주석을 추가할 것을 권장합니다.

예상대로 동작하지 않을 수 있기 때문인데요, 자세한 사항은 공식 문서에서 설명하고 있습니다.

 

 

@Transactional의 적용 범위

@Transactional의 사용 시, 예상대로 동작하지 않을 수 있는 사례 한 가지를 소개하고자 합니다.

그것은 바로 내부 호출입니다.

public class Service {
    privata final Dao dao;


    public saveResult(int userId) {
        this.saveResultByDao(userId); // Dao를 사용하는 로직을 새로운 메서드로 분리
    }

    @Transactional
    public saveResultByDao(int userId) {
        this.dao.saveNameById("name", userId);
        this.dao.saveAgeById(40, userId);
    }
}

(상기 코드는 상위의 ControllerServicesaveResult() 메서드를 호출한다고 가정하겠습니다.)

위의 코드는, saveResult() 메서드 내부에서 Dao를 사용하여 DB를 수정하는 로직을 분리한 코드입니다.

nameage를 저장하는 행위가 하나의 트랜잭션으로 묶일 것 같지만, 그렇지 않습니다.

내부에서 호출하는 함수의 @Transational 애너테이션이 무시되기 때문입니다.

 

 

@Transactional은 프록시 모드에서 작동하고, 프록시를 통해 들어오는 외부 메서드 호출만 가로채기 때문입니다. 이를 정확하게 이해하려면 프록시 패턴을 사용하는 스프링 AOP를 이해해야 합니다. 본 아티클에서는 이정도로만 다루겠습니다.

(설정을 통해 모드를 변경하면, 내부 호출도 @Transactional을 적용할 수 있다고 합니다. 자세한 내용은 공식 문서를 참고해주세요.)

 

정리하자면, @Transactional은 프록시를 통해 들어오는 외부 메서드 호출만 가로채기 때문에 내부에서 호출되는 메서드에 추가된 @Transactional은 무시됩니다.

 

 


 

 

잘못된 내용이나 궁금하신 점을 댓글로 남겨주시면 감사하겠습니다.

감사합니다!

 

 

 

 

반응형
반응형

자동 언박싱 프로세스

자바의 primitive 타입은 Wrapper 타입으로 매핑될 수 있습니다.

int ↔ Integer

long ↔ Long 등..

 

primitive 타입을 써야할 때 Wrapper 타입을 사용하거나, 그 반대인 경우

자동으로 형이 타입이 변환됩니다.

 

Java 1.5부터 지원된 자동 언박싱 프로세스(Auto-unboxing process) 덕분인데요,

간단한 코드 예시로 알아보겠습니다.

public class Customer {
    private long id;
    private String firstName, lastName;

    public Customer(long id, String firstName, String lastName) {
        this.id = id;
        this.firstName = firstName;
        this.lastName = lastName;
    }
}

위의 코드는, 예시코드에서 사용할 Customer 클래스입니다.

 

// primitive 타입 사용
long number = 1;
String firstName = "first";
String lastName = "last";
new Customer(number, firstName, lastName); // 생성 성공

// Wrapper 타입 사용
Long wrapperedNumber = new Long(1);
String firstName = "first";
String lastName = "last";
new Customer(wrapperedNumber, firstName, lastName); // 생성 성공

long 타입을 인자로 받는 Customer의 생성자에 Long 타입을 전달해도 생성에 성공합니다.

(반대의 경우에도 성공합니다.)

 

이렇게 필요한 경우에 long에서 Long으로, 혹은 Long에서 long으로 boxing 또는 unboxing하는 기능이 자바 내부적으로 지원됩니다.

Boxing, Unboxing 실행 시간

하지만 boxing, unboxing에 드는 리소스를 무시할 수 없습니다.

아래에서는 테스트 코드를 통해,

  • Primitive 타입만 사용했을 때
  • Boxing만 추가되었을 때
  • Unboxing만 추가되었을 때
  • Boxing / Unboxing이 모두 추가되었을 때

4가지 케이스에 대한 실행 시간을 측정해보겠습니다.

@Test
void 래퍼_클래스와_기본값_생성자에_인자로_전달_후_연산시간_비교() {

    String firstName = "first";
    String lastName = "last";

    long startTime;
    long endTime;

    //  Primitive 타입만 사용
    startTime = System.nanoTime();
    for (int i = 0; i < 100_000; i++) {
        long primitiveNumber = 1;
        new Customer(primitiveNumber, firstName, lastName);
    }
    endTime = System.nanoTime();
    System.out.println("Primitive 타입만 사용 : " + (endTime - startTime) + "ns");

    // Unboxing만
    Long wrapperNumber = new Long(1); // 시간 측정 전에 Boxing한 객체 생성
    startTime = System.nanoTime();
    for (int i = 0; i < 100_000; i++) {
        new Customer(wrapperNumber, firstName, lastName); // 생성자 내부에서 Auto unboxing
    }
    endTime = System.nanoTime();
    System.out.println("Unboxing만 : " + (endTime - startTime) + "ns");

    // Boxing만
    startTime = System.nanoTime();
    for (int i = 0; i < 100_000; i++) {
        Long wrapperNumberInner = new Long(1); // Boxing
        new Customer(1, firstName, lastName); // Boxing한 값 사용하지 않고 Primitive 타입 전달
    }
    endTime = System.nanoTime();
    System.out.println("Boxing만 : " + (endTime - startTime) + "ns");

    // Boxing + Unboxing
    startTime = System.nanoTime();
    for (int i = 0; i < 100_000; i++) {
        Long wrapperNumberInner = new Long(1); // Boxing
        new Customer(wrapperNumberInner, firstName, lastName); // 생성자 내부에서 Auto unboxing
    }
    endTime = System.nanoTime();
    System.out.println("래퍼 클래스로 생성 : " + (endTime - startTime) + "ns");
}

// 출력 :
// Primitive 타입만 사용 : 2721250ns
// Unboxing만 : 3207125ns
// Boxing만 : 4144833ns
// 래퍼 클래스로 생성 : 5515459ns

Auto unboxing, 그리고 Boxing을 통한 Wrapper 클래스 생성 모두 비용이 증가함을 알 수 있습니다.

생성 횟수에 비해 많이 차이나는 수준은 아니지만, 불필요한 Boxing과 Unboxing을 지양할 필요는 있을 것 같습니다.

 

감사합니다!

 

 

 

 

반응형
반응형

 

 

 

제네릭이란? (타입 파라미터)

제네릭(Generic)은 결정되지 않은 타입을 파라미터로 처리하고,

실제 사용할 때 파라미터를 구체적인 타입으로 바꾸는 기능입니다. (Java 5에서 지원 시작)

(여기서 제네릭은 기능을 나타내는 단어라는 것에 유의해야 합니다.)

 

컬렉션(리스트, 맵 등), 스트림, Optional 등의 내부 구현에서도 쓰이고,

활용도가 높은 만큼 꼭 이해해야 하는 자바의 기능 중 하나입니다.

 

다음과 같은 이점이 있습니다.

  • 다양한 타입을 사용하는 클래스(인터페이스)를 만들 수 있다. (타입 변수의 다형성 지원)
    • 코드가 유연해집니다.
    • 불필요한 타입 변환을 제거할 수 있습니다. → 성능 개선
  • 컴파일 시 강한 타입 체크를 할 수 있습니다. → 런타임 에러 방지

 

제네릭 이전에는 다양한 타입이 필요할 경우 Object로 선언하고, 강제 타입 변환(Casting)을 했었습니다.

이를 생각하면 제네릭의 이점이 더 확연하게 보일 것입니다.

// 제네릭을 사용하지 않은 예
class Box {
    private final String content;

    public String getContent;
}

위의 코드에서 Box의 내용물(content)에는 String만 들어갈 수 있습니다.

하지만 BoxIntegerDate 같은 것도 담고 싶다면, 하나의 타입마다 Box를 새로 만들어야 합니다.

class IntegerBox {
    private final Integer content;
    ...
}

class DateBox {
    ...
}

 

제네릭을 사용하면, 타입을 인스턴스 생성 시에 결정할 수 있습니다.

class Box<T> {
    private final T content;

    public T getContent {
        return this.content;
    }
}

Box<String> boxOfString = new Box<String>(); // 좌변과 우변이 모두 String이면 -> 우변의 String 생략 가능
Box<Integer> boxOfInteger = new Box<Integer>();

 

위에서는 class 단위에서 사용하는 타입에 제네릭을 사용했습니다.

더 작은 단위로 메서드 내부에서 사용하는 타입에도 제네릭을 사용할 수 있습니다.

우선 간단한 용례만 보고, 밑에서 자세히 설명하겠습니다.

public <T> T returnAsItIs(T value) {
    T newValue = value; // T라는 타입을 사용할 수 있다.
    return value;
}

 

 

여기서 세 가지 용어를 설명하겠습니다.

 

제네릭 타입

우선 위의 코드 중, Box라는 class 내부에서 사용할 타입에 제네릭(기능)을 사용했습니다.

이렇게 제네릭(기능)을 사용하여 결정되지 않은 타입을 파라미터로 가지는 클래스인터페이스제네릭 타입이라고 합니다.

위의 예시에서는 Box가 제네릭 타입입니다.

 

제네릭 메서드

위의 returnAsItIs() 메서드는 결정되지 않은 타입을 파라미터로 가지고, 메서드 내부에서 사용합니다.

이런 메서드를 제네릭 메서드라고 합니다.

 

타입 파라미터

제네릭 타입, 제네릭 메서드에서 가지고 있는 타입(위에서는 T)을 타입 파라미터라고 합니다.

제네릭 타입을 생성할 때(Box를 생성할 때 new Box<String>()), 제네릭 메서드를 호출할 때

타입 파라미터에 정해진 타입을 지정해서 전달해야 합니다.

 

 

 

 

제네틱 타입과 제네릭 메서드

이번에는 위에서 설명한 제네릭 타입와 제네릭 메서드를 좀 더 상세하게 알아보겠습니다.

 

제네릭 타입의 사용

public class Box<A, B, ...> { ... }
public interface Box<A, B, ...> { ... }

위처럼 결정되지 않은 타입을 파라미터(A, B, …)로 가지는 클래스와 인터페이스를 제네릭 타입이라고 합니다.

제네릭 타입을 사용하려면, 각각의 타입 파라미터에 구체적인 타입을 지정해야 합니다. (그렇지 않으면 암묵적으로 Object가 사용됩니다.)

 

public class Box<K, V> {
    K key;
    V Content;

    public Box(K key, V content) {
        this.key = key;
        this.content = content;
    }

    public V getContentByKey(K key) {
        if (key.equals(this.key)) {
            return content;
        }
        return null;
}

Box<String, Integer> box1 = new Box<String, Integer>("key1", 1);
Box<String, Integer> box2 = new Box<>("key2", 2); // 좌변과 우변에 지정하는 타입 파라미터가 같으면 생략 가능

box1.getContentByKey("key1") // 1 반환
box2.getContentByKey("key3") // null 반환

위와 같이 인스턴스 생성시에 지정한 K(String), V(Integer) 타입을, 제네릭 타입 내부에서 자유롭게 사용할 수 있습니다.

 

제네릭 메서드의 사용

public <T> Box<T> boxing(T content) {
    return new Box<T>(content);
}

public <A, B> void printTwoArgument(A a, B b) {
    System.out.println(a);
    System.out.println(b);
}

위처럼 결정되지 않은 타입을 파라미터(A, B, …)로 가지는 메서드를 제네릭 메서드라고 합니다.

 

boxing() 메서드를 호출해보겠습니다.

Box<String> boxOfString = this.<String>boxing("hi"); // 타입 파라미터 전달

위의 코드처럼, 제네릭 메서드를 호출 당하는 인스턴스와에 세부 타입을 지정해서 넘겨주어야 합니다.

 

그런데 boxing()은 인자로 타입 파라미터와 같은 타입을 사용합니다.

이 경우, 인자를 통해 타입 파라미터를 유추할 수 있기 때문에 생략이 가능합니다.

Box<String> boxOfString = boxing("hi") // "hi"가 String 타입이므로, T에 String 전달

 

제네릭 타입과 제네릭 메서드의 타입 파라미터가 중복될 때

public class Box<T> {
    T content;

    public Box(T content) {
        this.content = content;
    }

    public <T> T getInputAsItIs(T value) {
        return value;
    }
}

위의 코드에서, boxOfString은 제네릭 타입 BoxString을 타입 파라미터 T로 전달했습니다.

그런데 Box 내부의 메서드 getInputAsItIs() 에서 타입 파라미터 T를 재정의합니다.

 

Box<String> boxOfString = new Box<>("hi");
System.out.println(boxOfString.content.getClass().getSimpleName()); // 출력 : String
System.out.println(boxOfString.getInputAsItIs(1).getClass().getsimpleName()); // 출력 : Integer

이런 경우, 위의 코드 출력에서 알 수 있듯이

메서드 내부 블록에서는 새롭게 정의한 T를 사용하고,

그 밖에서는 Box 인스턴스 생성 시 전달 받은 타입을 사용합니다.

 

 

 

 

제한된 타입 파라미터

위에서 사용한 제네릭은 타입 파라미터에 모든 타입이 들어올 수 있습니다.

하지만 내부에서 덧셈 후 결과를 반환해야 한다면, 타입을 Number 클래스의 하위 클래스들로 제한할 필요가 있습니다.

그럴 경우, 제한된 타입 파라미터를 사용할 수 있습니다.

 

public class NumberBox<T extends Number> {
    T number;

    public NumberBox(T number) {
        this.number = number;
    }
}

위의 NumberBox 클래스는 타입 파라미터로 Number를 상속받은 클래스만 전달할 수 있습니다.

 

new NumberBox<Integer>(1); // -> 생성 성공
new NumberBox(1); // -> T에 Integer 전달
new NumberBox<String>("hi"); // 컴파일 에러 발생(Type parameter 'java.lang.String' is not within its bound; should extend 'java.lang.Number')

이처럼 NumberBox의 타입 파라미터에는 Number를 상속받은 클래스만 들어갈 수 있습니다.

 

public class IntegerBox<T super Integer> {
    T integer;
    ...
}

위와 같이 super 키워드를 사용하면, Integer 클래스의 상위 클래스들만 들어갈 수 있습니다.

 

new IntegerBox<Integer>(1); // 생성 성공
new IntegerBox<Number>(1); // 생성 성공
new IntegerBox<String>("hi"); // 컴파일 에러 발생

제한된 타입 파라미터는, 타입 파라미터입니다.

따라서 타입 파라미터에만 사용될 수 있습니다.

class Box<T extends Number> { ... } // 사용 가능

public <T extends String> void printValue(T str) { ... } // 사용 가능

 

타입 파라미터가 아닌 곳에는 사용할 수 없습니다.

public <T> T extends String void printValue(T str) { ... } // 사용 불가능

public <T> void printValue(T extends String str) { ... } // 사용 불가능

 

 

 

 

와일드카드 타입 파라미터

제네릭 타입을 인자, 반환 타입, 변수의 타입 선언 용도로 사용할 때,

타입 파라미터로 와일드카드(?)를 사용할 수 있습니다.

의미는 다음과 같습니다.

  • ? : 모든 클래스 사용 가능(? extends Object 와 동일)
  • ? extends A : A를 상속 받은 모든 타입 사용 가능
  • ? super B : B의 모든 상위 타입 사용 가능
  •  

위와 같은 상속 구조를 가정하고, 와일드카드에 대해서 상세하게 알아보겠습니다.

 

public void register(Box<? extends Student> box) { // Student, HighStudent, MiddleStudent 만 가능
    System.out.println(box.getContent());
}

Box<Student> boxOfStudent = new Box<>(new Student());
Box<? extends Student> boxOfHighStudent = new Box<>(new HighStudent());
Box<Worker> boxOfWorker = new Box<>(new Worker());

register(boxOfStudent); // 성공
register(boxOfHighStudent); // 성공
register(boxOfWorker); // 컴파일 에러

위의 코드를 통해, 제네릭 타입의 타입 파라미터 범위를 제한하기 위해 와일드카드를 사용할 수 있음을 알 수 있습니다.

 

하지만 제네릭 타입이 아닌 일반 타입에는 와일드카드를 쓸 수 없습니다.

public void register(? extends Student student) { ... } // 불가능

? extends Number number = 1; // 불가능

 

 

 

 

제네릭의 변성(공변, 반공변)

변성이란,

타입타입 매개변수로서 타입 생성자에 넣었을 때 계층 관계가 유지되는지를 결정하는 성질입니다. (

?? 뭔 소리야

)

 

말이 좀 어려운데요,

예를 들면, Cat extends Animal인 경우에서

List<T>(타입 생성자)T(타입 매개변수)Cat, Animal(타입)을 넣으면 List<Cat>, List<Animal>이라는 새로운 타입이 생성됩니다.

이때 List<Cat>LIst<Animal>의 하위 타입인지, 혹은 그 반대인지, 아니면 아무런 연관이 없는지를 결정하는 것을 말합니다.

 

(TMI) 변성의 정의 추가 설명

제네릭 타입은 일종의 타입 생성자입니다.

class Box<T> { ... }

Box<T>TString을 넣으면 Box<String>이라는 새로운 타입이 생성됩니다.

따라서 제네릭 타입에 대해 변성을 고려해야 합니다.

 

타입 생성자에는 제네릭 타입만 있는 것은 아닙니다. 배열도 일종의 타입 생성자이고(intint[]), JavascriptPromise나, RustResult 등도 타입 생성자입니다.

 

본 아티클에서는, 자바의 제네릭(Generic)에 관한 변성에 대해서 정리해보겠습니다.

 

변성의 종류

변성에는 4가지 종류가 있습니다. (해당 정의가 어렵게 느껴진다면, 아래의 List 예시로 넘어가도 좋습니다.)

I를 타입 매개변수 하나를 받는 타입 생성자라고 할 때,

  • 공변(Covariance) : S <: T 이면, I<S> <: I<T> 이다. (변환된 타입의 서브타입 관계가 기존 관계와 동일)
  • 반공변(Contravariance) : S <: T 이면, I<T> <: I<S> 이다. (변환된 타입의 서브타입 관계가 기존 관계에 배반)
  • 이변(Bivariance) : 공변하면서 반공변한다.
  • 무공변(Invariant) : 공변하지도 반공변하지도 않는다.

(S <: TST의 서브타입이다)

 

변성의 종류를 List로 표현

변성의 4가지 종류를 List<T>를 예로 들어 표현해보겠습니다.

  • 공변(Covariance) : CatAnimal의 하위 타입이면, List<Cat>List<Animal>의 하위 타입이다.
    • List<Animal> animals = new List<Cat>(cat1, cat2); 왼쪽의 의사코드 실행이 가능해야 함.
  • 반공변(Contravariance) : CatAnimal의 하위 타입이면, List<Animal>List<Cat>의 하위 타입이다.
    • List<Cat> cats = new List<Animal>(cat1, cat2); 왼쪽의 의사코드 실행이 가능해야 함.
  • 이변(Bivariance) : 공변하면서 반공변한다.
    • List<Animal> animals = new List<Cat>(cat1, cat2);
    • List<Cat> cats = new List<Animal>(cat1, cat2);
    • 위의 의사코드가 모두 실행 가능해야 함.
  • 무공변(Invariant) : 공변하지도 반공변하지도 않는다.

 

제네릭은 무공변이다

예상하셨겠지만, 자바의 제네릭은 무공변입니다.

List<Animal> 타입에 List<Cat>을 저장할 수도,

List<Cat> 타입에 List<Animal>을 저장할 수도 없습니다.

(반면 배열은 공변입니다.)

 

제네릭이 무공변이라는 것은, 타입의 다형성을 이용할 수 없다는 뜻입니다.

public List<Object> produce(List<Object> values) {
    return new List.of(values);
}

List<String> strings = List.of("hi", "bye");
produce(strings);

위와 같이 코드를 짜는 것이, 다형성을 구현하는 객체지향 프로그래밍의 핵심 중 하나입니다만..

제네릭 타입은 위의 코드 실행이 불가능합니다.

 

하지만, 와일드카드 타입 파라미터를 사용한다면 가능하게 만들 수는 있습니다.

 

제네릭 공변으로 만들기

ArrayList<? extends Animal> animals = new ArrayList<Cat>();

위와 같이 extends를 사용하면 Cat-Animal의 관계를 ArrayList 타입 적용 후에도 공변으로 만들 수 있습니다.

 

public List<Object> produce(List<? extends Object> values) { ... } // 사실 ? extends Object는 그냥 ?와 동일합니다.

List<String> strings = List.of("hi", "bye");
produce(strings);

위에서 불가능했던 타입 다형성의 구현이 가능해졌습니다.

 

제네릭 반공변으로 만들기

ArrayList<? super Cat> cats = new ArrayList<Animal>();

위와 같이 super 키워드를 사용하면 Cat-Animal의 관계를 ArrayList 타입 적용 후에 반공변으로 만들 수 있습니다.

 

class IntegerFactory {
    private final List<Integer> numbers;

    public IntegerFactory() {
        this.numbers = new ArrayList<>();
        this.numbers.add(1);
    }

    public void consume(List<? super Integer> values) {
        values.add(this.numbers.get(0));
    }
}

IntegerFactory integerFactory = new IntegerFactory();
List<Number> values = new ArrayList<>();

integerFactory.consume(values); // values를 consume 함수에 보내 1을 받아온다.
System.out.println(values); // 출력 : [1]

consume() 메서드의 인자로 List<Number>를 보낼 수 있게 되었습니다.

 

 

 

 

제네릭 PECS

PECSProducer-Extends / Consumer-Super 의 약자입니다.

눈치채셨을 수도 있는데, 위의 공변, 반공변 예시 코드에서 produce, consume 이라는 메서드명을 사용했습니다.

 

외부에서 온 데이터(매개변수)를 가지고 생산(Produce)에 사용하면 <? extends T> 를 사용하고,

외부에서 온 데이터(매개변수)를 가지고 소비(Consumer)에 사용하면 <? super T>를 사용하라는 원칙입니다.

 

이는 조슈아 블로흐의 이펙티브 자바에서 소개된 공식입니다.

잘만 이해하면 제네릭의 공변, 반공변성 설정을 쉽게 하도록 도와줍니다. 그런데,

이해하기가 쉽지 않네요.. 개인적으로 직관적인 공식은 아닌 것 같습니다.

 

생산, 소비의 기준이 ‘외부에서 온 데이터를 가지고’인데,

이는 Producer-Extends / Consumer-Super 라는 표현에 들어 있지 않습니다.

독립적인 정보로 외워야 합니다.

 

 

각설하고, 위에서 예시로 들었던 코드보다

조금 더 상세한 예시로 PECS를 설명해보겠습니다.

 

class CustomList<T> {
    Object[] elements = new Object[10]
    int pointer = 0;

    // Prodece - Extends : 공변성 부여
    public void produce(List<? extends T> values) {
        for (T element : values) {
            elements[index++] = element;
        }
    }

    // Consume - Super : 반공변성 부여
    public void consume(List<? super T> values) {
        for (Object element : this.elements) {
            values.add((T)element);
        }
    }
}

이처럼 생산, 소비의 관점에서 extends, super를 구분해서 사용할 수 있습니다.

그리고 각각은 공변성, 반공변성을 구현합니다.

 


 

제네릭의 뜻과 사용법,

제한된 타입 파라미터와 와일드카드,

변성과 그것을 구현할 수 있는 PECS 공식까지 알아보았습니다.

 

궁금하신 점이 있다면 질문을, 잘못된 점이 있다면 지적 부탁드립니다.

감사합니다!

 

 

 

반응형
반응형

 

컬렉션을 반환할 때, 원본의 참조 값을 그대로 반환하는 경우

원하지 않는 곳에서 컬렉션을 직접 변경할 수 있습니다.

값이 추가되거나 수정되거나 삭제되어 문제가 발생할 수 있습니다.

 

따라서, 수정이 필요하지 않은 경우라면 컬렉션의 원본을 그대로 반환하기보다

복사해서 보내는 것이 좋은 방법입니다.

 

본 아티클에서는

첫째로, 복사의 여러 종류에 대해 알아보겠습니다.

둘째로, 복사의 종류와 불변 여부를 고려한, 다양한 복사의 사용법에 대해서 알아보겠습니다.

 

** 편의상 컬렉션의 여러 종류 중 리스트를 중점적으로 다뤄보겠습니다.

 

 


복사의 종류

그에 앞서, 예시로 사용할 Person 클래스를 다음과 같이 정의하겠습니다.

class Person {
    private final String name;
    private final int age;

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

복수의 Person을 담고 있는 persons는 다음과 같습니다.

List<Person> persons = new ArrayList<>();
persons.add(new Person("우진", 20);
persons.add(new Person("체인저", 30);

 

 

얕은 복사

얕은 복사는 원본의 참조가 유지되는 복사입니다.

List<Person> newPersons = persons;

newPersons는 새로운 객체이지만, persons와 같은 주소를 참조하는 참조형 객체가 됩니다.

Stack 메모리 상에서 personsnewPersons는 별도의 객체이지만, 같은 주소값을 가지고 있습니다.

그리고 Heap 메모리 영역에 있는 하나의 객체를 가리키고 있습니다.

 

위의 결과처럼, 둘은 동일한 객체입니다.

 

 

방어적 복사

방어적 복사는 원본의 참조가 유지되지 않는 복사입니다.

List<Person> newPersons = new ArrayList<>(persons);

ArrayList의 생성자는, Collection 타입을 인자로 받으면 해당 컬렉션의 요소들을 가지고 있는 새로운 ArrayList를 생성합니다. (LinkedList와 같은 다른 구현체도 마찬가지입니다.)

얕은 복사와 얼핏 보면 비슷하지만, 다른 점이 있습니다.

  Stack 영역에서 Stack 영역에서 가지고 있는 주소 Heap 영역
얕은 복사 분리된 객체 동일함 하나의 객체
방어적 복사 분리된 객체 서로 다름 서로 다른 객체

위의 결과처럼, 둘은 동일한 객체가 아닙니다.

 

하지만, 방어적 복사도 완벽한 복사 방식은 아닙니다.

내부에 가지고 있는 요소가 참조형이라면, 같은 객체를 참조하고 있을 수 있습니다.

 

 

위의 결과처럼, personsnewPersons의 0번째 요소는 같은 객체를 참조합니다.

동일한 객체라고 볼 수 있습니다.

 

 

깊은 복사

깊은 복사는 값 자체를 복사하는 것으로, 방어적 복사의 맹점을 해결합니다.

컬렉션 자체도 새로운 객체이면서, 내부에 있는 객체들도 새로운 객체가 됩니다.

원리는 간단합니다.

새로운 틀(컬렉션)을 만들고, 그 안에 다시 새로운 요소들을 생성해서 집어넣는 것입니다.

public Person copy() {
    return new Person(this.name, this.age);
}

우선, 위와 같이 Person 클래스 내부에 copy()라는 메서드를 구현합니다.

내부 상태가 동일한 새로운 Person 인스턴스를 반환합니다.

 

List<Person> persons = new ArrayList<>();
persons.add(new Person("우진", 20));
persons.add(new Person("체인저", 30));

List<Person> newPersons = new ArrayList<>();

for (Person person : persons) {
    newPersons.add(person.copy());
}

그리고 위와 같이 새로운 틀을 만든 후,

원본 컬렉션을 순회하며 새로운 객체를 생성 및 삽입합니다.

 

위의 결과를 통해,

깊은 복사가 컬렉션, 요소를 새로 만들어 참조 연결을 끊는다는 것을 알 수 있습니다.

하지만 새로 만든 요소들의 동등성은 유지된다는 것도 말이죠.

 

(여기서 동등성을 판단하는 메서드는 아래 코드와 같이 새롭게 작성했습니다.)

public boolean equals(Person person) {
    return this.name.equals(person.name) && this.age == person.age;
}

 

 

 

 

컬렉션 복사의 사용

참조 할당 - 얕은 복사

List<Person> newPersons = persons;
  • 원본과 같은 주소를 참조
  • 복사본을 수정하면 원본이 같이 수정

 

원본을 생성자의 인자로 삽입 - 방어적 복사

List<Person> newPersons = new ArrayList<>(persons);
  • 원본과 다른 주소를 참조
  • 복사본을 수정해도 원본에 영향이 없음
  • 다만, 요소가 참조형이라면 각 요소들은 참조 할당됨

 

List.copyOf() - 방어적 복사 + 불변

List<Person> newPersons = List.copyOf(persons);
  • 원본과 다른 주소를 참조
  • 복사본은 수정 자체가 불가능
    • add, remove, setUnsupportedOperationException 발생
    • ImmutableCollections를 생성하여 반환하기 때문
    • List.of() 메서드를 통해 초기화한 리스트도 같은 이유로 수정이 불가능

 

Collections.unmodifiableList() - 참조 연결 + 불변

List<Person> newPersons = Collections.unmodifiableList(persons);
  • 원본과 다른 주소를 참조
  • 원본의 변경이 복사본에 그대로 반영
  • 복사본은 수정 자체가 불가능
    • add, remove, setUnsupportedOperationException 발생

 

컬렉션 순회하여 새로운 객체 생성 - 깊은 복사

List<Person> newPersons = new ArrayList<>();

for (Person person : persons) {
    newPersons.add(person.copy()); // copy() 메서드는 추가 구현 필요
}
  • 원본과 다른 주소를 참조
  • 참조형의 요소들도 다른 주소를 갖는 새로운 객체

깊은 복사한 리스트를 불변으로 반환하고 싶다면, List.copyOf()Collections.unmodifiableList()를 사용하면 됩니다.

List<Person> newFinalList = List.copyOf(newPersons);

 

 

 

 

위의 다양한 복사 방법에 대해 간단한 테스트를 해보았습니다.

@Test
void copy() {
    // List 초기화
    List<Integer> list = new ArrayList<>(Arrays.asList(1, 2, 3));
    list.add(4);
    System.out.println("list : " + list);

    // 1. 방어적 복사 - new ArrayList
    List<Integer> listNew = new LinkedList<>(list);
    listNew.add(5);
    System.out.println("listNew : " + listNew);

    // 2. 방어적 복사 + 불변 - List.copyOf
    List<Integer> listCopyOf = List.copyOf(list);
//        listCopyOf.add(6);    // UOE 발생
//        listCopyOf.set(0, 6);
    list.add(5);
    System.out.println("listCopyOf : " + listCopyOf);

    // 3. 참조 연결 + 불변 - Collections.unmodifiableList
    List<Integer> listUnmodifiable = Collections.unmodifiableList(list);
//        listUnmodifiable.add(7);  // UOE 발생
//        listUnmodifiable.set(0, 6);   // UOE 발생
    list.add(6);
    System.out.println("listUnmodifiable : " + listUnmodifiable);

    // 4. 불변 - List.of
    List<Integer> listOf = List.of(1, 2, 3, 4);
//        listOf.add(5);    // UOE 발생

    System.out.println("list at last : " + list);
}

// 결과
list : [1, 2, 3, 4]
listNew : [1, 2, 3, 4, 5]
listCopyOf : [1, 2, 3, 4]
listUnmodifiable : [1, 2, 3, 4, 5, 6]
list at last : [1, 2, 3, 4, 5, 6]

Process finished with exit code 0

 

 

 

 

List 아닌 컬렉션

Map, Set과 같은 컬렉션도, 위의 내용에 부합하는 메서드들이 지원됩니다.

Collections.unmodifiableMap()
Collections.unmodifiableSet()

Map.copyOf()
Set.copyOf()

// etc..

 


이상에서 컬렉션의 복사 종류와, 다양한 복사 방법에 대해서 알아보았습니다.

글이 잘못된 점이나 궁금하신 점이 있다면 지적 부탁드리며,

글을 줄이겠습니다.

감사합니다.

 

 

 

 

반응형

+ Recent posts