본문 바로가기

Spring Boot

[아이디어] 복합키(Composite-key), 쉽게 CRUD 해보자

728x90

데이터베이스 설계를 하다 보면 복합키(Composite-key)를 활용해야 하는 경우를 피할 수 없을 때도 있다.

만약 우리가 사용자에게 CRUD를 제공하는 웹 서비스를 개발한다면 이 복합키를 어떻게 처리해야 할 지 분명 고민에 빠질 것이다.

 

이 포스트에서는 AS-IS와 TO-BE 예제를 통해 Converter를 이용해 복합키를 지닌 엔티티의 CRUD를 매우 간편하게 처리할 수 있는 아이디어를 다룬다.

 


복합키를 가진 엔티티 정의

우선 AS-IS와 TO-BE를 설명하기 전에 복합키를 가진 엔티티를 정의해 보자

우선 Room.class 라는 엔티티를 정의하였다. 이 Room은 학년-반으로 이루어진 복합키를 가진다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Getter
@Setter
@Entity
@Table(name = "room")
@NoArgsConstructor
@AllArgsConstructor
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
public class Room {
 
  @Valid
  @EmbeddedId
  @EqualsAndHashCode.Include
  private RoomId id = new RoomId();
 
  @JsonIgnore
  @Column(columnDefinition = "boolean default false", nullable = false, insertable = false)
  private boolean removed = false;
 
  public Room(RoomId id) {
    this.id = id;
  }
}
 
cs

 

다음은 Room의 복합키 클래스인 RoomId.java  즉 ID클래스이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Getter
@Setter
@Embeddable
@NoArgsConstructor
@AllArgsConstructor
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
public class RoomId implements Serializable {
 
  @NotNull
  @EqualsAndHashCode.Include
  @Column(name = "grade")
  private Integer grade;
 
  @NotBlank
  @Size(max = 30)
  @EqualsAndHashCode.Include
  @Column(name = "name", columnDefinition = "varchar(30)")
  private String name;
}
 
cs

 

AS-IS

우리는 아래와 같이 강의실에 대한 CRUD를 제공하는 웹 서비스를 개발하고자 한다.

PK는 복합키로 이루어져있으며, 사용자는 화면에서 목록 조회, 추가, 수정, 삭제의 작업을 할 수 있다.

만약 단일키라면 /room/3 과 같은 형식의 PathVariable을 주어 해당 엔티티를 특정 할 수 있을 것이다. 하지만 복합키라면?

 

단순하게 생각하면 /room/1/1 처럼 키를 나열하면 되겠지 라고 생각할 수 있다. 하지만 아래 UI를 보면 알겠지만 다중 건에 대한 삭제가 가능하다. 즉, ID를 List로 받아 처리할 수 있어야 한다는 것

 

 

 

TO-BE

우리는 이 문제의 해결을 위해 직렬화-역직렬화 매커니즘을 이용해야 한다.

직렬화를 사용하게 되면 단일키를 사용하는 것과 같은 구조로 CRUD 구현이 가능해지는데 아래 예제를 통해 설명하도록 하겠다.

 

1. 직렬화

 

먼저 RoomId.java 코드의 변경이 필요하다.

주의깊게 볼 부분은 toString 메소드와 fromHash 메소드인데 toString은 직렬화, fromHash는 역직렬화를 위한 메소드이다.

(본 포스트를 읽는 시간을 절감하기 위해 코드를 매우 단순하게 작성하였습니다. 추후 이를 응용하여 클래스 상속 체계로 구축하면 매우 편리합니다.)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
@Getter
@Setter
@Embeddable
@NoArgsConstructor
@AllArgsConstructor
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
public class RoomId implements Serializable {
 
  @NotNull
  @EqualsAndHashCode.Include
  @Column(name = "grade")
  private Integer grade;
 
  @NotBlank
  @Size(max = 30)
  @EqualsAndHashCode.Include
  @Column(name = "name", columnDefinition = "varchar(30)")
  private String name;
 
  public static RoomId fromHash(String hash) {
    String[] fields = CipherUtil.decodeBase16(hash).split(Pattern.quote(Constant.HASH_SEPARATOR));
    return new RoomId(Integer.parseInt(fields[0]), fields[1]);
  }
 
  @Override
  public String toString() {
    return CipherUtil.encodeBase16(String.format("%d%s%s", grade, Constant.HASH_SEPARATOR, name));
  }
}
cs

이렇게 toString 메소드를 구현하게 되면 View에서 자동으로 직렬화된 값을 이용하게 된다.

아래는 AS-IS에서 보았던 UI의 목록을 그려주는 코드로 r.id 즉 id필드를 출력하면 자동으로 직렬화된 값이 출력된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
<tr th:each="r : ${roomList}">
  <td scope="row">
    <div class="checkbox checkbox-info">
      <input th:id="|check${r.id}|" th:value="${r.id}" name="ids" type="checkbox">
    </div>
  </td>
  <td>
    <a th:href="|@{/room/edit/}${r.id}${queryString}|" th:text="${r.id.grade}"></a>
  </td>
  <td>
    <a th:href="|@{/room/edit/}${r.id}${queryString}|" th:text="${r.id.name}"></a>
  </td>
</tr>
cs

1-1 의 복합키 필드는 직렬화를 통해 317C31 으로 변환됨을 알 수 있다.

 

2. 역직렬화

 

이제 직렬화를 했으니 역직렬화를 통해 클래스로 변환시킬 단계이다.

가장 먼저 Converter 클래스가 필요하다. 이 Converter는 직렬화된 값이 역직렬화될 대상 클래스로 변환되기 위해 필요한 로직을 정의하고 있다.

1
2
3
4
5
6
7
8
9
10
11
@Component
public class StringToRoomIdConverter implements Converter<String, RoomId> {
 
  @Override
  public RoomId convert(String s) {
    if (StringUtils.isNotBlank(s)) {
      return RoomId.fromHash(s);
    }
    return null;
  }
}
cs

이제 모든 준비는 끝났다. 아래는 컨트롤러에서 요청을 처리하는 코드이다.

1
2
3
4
5
6
7
8
  @GetMapping({"/edit""/edit/{id}"})
  public String edit(@PathVariable(required = false) RoomId id, Model model, RoomFilter filter) {
    Room room = (id == null) ? new Room()
        : roomService.findById(id).orElseThrow(DataNotFoundException::new);
 
    prepareModel(model, filter, room);
    return "room/edit";
  }
cs

여러 개의 목록을 삭제하는 경우에는?

별거 없다. 마찬가지로 List<RoomId> 로 받기만 하면 된다. 모든 일은 Converter가 알아서 한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
  @PostMapping("/delete")
  public String delete(@RequestParam("ids") List<RoomId> ids, RoomFilter filter,
      RedirectAttributes redirectAttr, HttpServletRequest request) {
 
    filter.fromQueryString(request.getQueryString());
 
    try {
      long count = roomService.deleteAllByIdIn(ids);
      redirectAttr.addFlashAttribute("type", Constant.TOAST_TYPE_SUCCESS);
      redirectAttr.addFlashAttribute("message"String.format("%d개의 데이터가 삭제되었습니다.", count));
    } catch (Exception e) {
      redirectAttr.addFlashAttribute("type", Constant.TOAST_TYPE_ERROR);
      redirectAttr.addFlashAttribute("message""데이터 삭제에 실패하였습니다. 다시 시도해주세요.");
    }
 
    return "redirect:/room?" + filter.getQueryString();
  }
cs

 

FAQ

1. 직렬화를 할 때 사용하는 CipherUtil의 코드가 궁금합니다.

1
2
3
4
5
6
7
8
9
10
11
@UtilityClass
public class CipherUtil {
 
  public static String encodeBase16(String text) {
    return BaseEncoding.base16().encode(text.getBytes(StandardCharsets.UTF_8));
  }
 
  public static String decodeBase16(String text) {
    return new String(BaseEncoding.base16().decode(text));
  }
}
cs

2. 직렬화 코드를 일일히 작성하면 관리할 코드가 너무 많아집니다. 자동으로 처리할 수는 없을까요?

Java Reflection API를 이용해 특정 인스턴스의 필드에 접근할 수 있습니다. 이를 이용하면 직렬화-역직렬화를 자동화 할 수 있으며, super클래스 하나만 잘 만들어 놓으면 상속을 통해 아주 편리하게 개발을 할 수 있습니다.

커스텀 어노테이션까지 만들어 놓으면 금상첨화인 셈이죠.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
  public static String getHash(Object obj) {
    List<Field> fieldList = Arrays.stream(obj.getClass().getDeclaredFields())
        .collect(Collectors.toList());
 
    List<String> idList = new ArrayList<>();
    fieldList.forEach(f -> {
      f.setAccessible(true);
      try {
        idList.add(f.get(obj).toString());
      } catch (Exception ignore) {
      }
    });
 
    return CipherUtil.encodeBase16(String.join(Constant.HASH_SEPARATOR, idList));
  }
cs

 

 

 

 

728x90