DB

여러분은 MySQL Json Type을 알고 있나요?

자바생 2023. 10. 7. 19:49
728x90

글을 쓰게 된 이유

 

커디에서는 현재 댓글 알림, 행사 알림이 하나의 테이블에서 관리되고 있습니다.

각 알림은 type으로 구분하고 있습니다.

 

 

Event 객체도 하나의 클래스로 관리하고 타입으로 이벤트 종류를 구분하고 있습니다.

 

public class UpdateNotificationEvent {

  private static final String UPDATE_NOTIFICATION_COMMENT_TYPE = "comment";
  private static final String UPDATE_NOTIFICATION_EVENT_TYPE = "event";

  private final Long receiverId;
  private final Long redirectId;
  private final String updateNotificationType;
  private final LocalDateTime createdAt;

  //constructor...
}

 

하지만 프로젝트를 진행하면서 알림에 관한 요구사항이 변경되었습니다.

 

 

알림 종류마다 필요로 하는 데이터가 변경되었고, 그 데이터들은 각 알림에 관한 특정한 데이터이기 때문에 추상화하기 어려워졌습니다.

이제는 하나의 클래스에서 관리할 수 없습니다.

 

 

 

이렇게 초기에도 요구사항이 많이 변경되는데 나중에는 다른 알림이 추가되는 것은 불가피하고,

이를 추상화하는 것도 한계가 존재한다고 생각했습니다. (지금 요구사항처럼요,,,)

 

 

 

처음에는 알림이라는 도메인을 추상화하여 상속을 사용하면 되지 않을까?라는 생각을 했습니다.

(위 이미지는 Event 객체를 관리하는 클래스이고, 제가 말한 알림 도메인은 실제 행동을 하는 객체입니다.)

 

하지만 "자식 클래스에 고유하게 존재하고 있는 칼럼들을 어떻게 처리하느냐?" 가 관건입니다.

 

자식 클래스를 구분하기 위해 instance of를 사용해야할텐데,, 이건 상속을 사용한 의미가 없다고 생각했습니다.

 

 

그렇다면 알림이 생길 때마다 테이블을 생성하고, 저장해야할까요?

 

너무 확장성이 떨어지고, 글을 작성하면서도 별로다라는 생각이 들었습니다.

 

 

요구사항 및 도메인 재정의

 

유연한 코드작성을 위해 초심으로 돌아가 요구사항 + 도메인에 관한 성격을 재정의해보았습니다.

 

1. 알림에 관한 요구사항들을 생각해 보면 무조건 저장 용도 + 조회에서만 사용

2. 검색이나 다른 추가 기능이 없다.

3. 읽었는지, 안 읽었는지 is_read에 관한 정보뿐이다.

 

 

 

알림을 하나의 테이블에 저장하고 싶은데,,

가지고 있는 칼럼들이 다르기 때문에 하나의 테이블에 저장할 수 없었습니다.

 

 

해당 고민을 구구에게 여쭤봤고, Json Type이라는 것을 알게 됐습니다.

 

 

Json Type?

 

Json Type은 MySQL에 있는 실제 데이터 타입입니다.

 

당연히 json type으로 저장되겠죠. 여기서 생각이 든 게 TEXT로도 저장할 수 있지 않을까?라는 의문이 들었습니다.

 

Json 값을 MySQL에 저장하기 위해서는 Json Type과 Text Type이 있는데,

그 둘을 비교해 주는 아주 자세한 을 발견할 수 있었습니다.

(Real MySQL 저자님의 글이어서 더욱 신뢰할 수 있었습니다)

 

 

아래 접은 글은 해당 내용을 요약한 거라 필요하신 분만 보시면 좋을 것 같습니다.

더보기

Json 데이터를 저장할 때 사용할 수 있는 타입은 Json 타입과 Text 타입이 있다.

 

MySQL은 TEXT든 JSON이든 데이터 값이 크기 대문에 데이터를 읽기 위해서는 external page에 접근한다.

 

그런데 Json의 특정 필드 값에 접근하려면 Json 파싱까지 추가적인 작업을 수행한다.

 

특정 Json 값에 접근하는 쿼리가 있다면 TEXT와는 차이가 크다.

왜냐하면 파싱 하는 작업이 추가로 들기 때문이다.

 

 

또한, MySQL에서 Json을 파싱 할 때 항상 full 파싱을 하므로 첫 번째 필드를 가져오나 마지막 필드를 가져오나 큰 차이가 없다.

 

파싱하지 않더라도 select data 작업 자체 또한 json 타입이 오래 걸린다.

그 이유는 text 칼럼의 경우 문자열로 해석하는 작업이 필요하고,

json 칼럼의 경우 MySQL 서버 내부적인 Binary Json 저장 포맷으로 변환해야 하기 때문

즉, 변환 작업이 훨씬 복잡해서 오래 걸린다.

 

 

Json 타입은 MySQL server-side에서 필드의 값을 조회 및 변경, 특정 필드에 대해 인덱스 생성,

특정 필드 변경 시 in-place 업데이트가 가능하다는 특징이 잇다.

 

TEXT 타입은 저장된 1MB Json 데이터를 변경하려면 통째로 업데이트해야 한다.

 

 

특정 필드에 대한 조회, 변경이 일어나지 않고, 인덱스를 생성할 일이 없으면 TEXT가 유리하다는 것을 알 수 있습니다.

 

커디 알림 특성 상 Json은 그냥 저장 용도이고, 따로 검색이 필요 없으며

그대로 값을 앞단에 넘겨주기 때문에 TEXT로 저장하는 게 효율적이다고 할 수 있습니다.

 

 

TEXT에서 어느 타입?

 

TEXT에는 크게 TEXT, MEDIUMTEXT, LONGTEXT가 있습니다.

 

TEXT : 65,535 bytes

MEDIUMTEXT : 16,777,215 bytes

LONGTEXT : 4,294,967,295 bytes

 

 

TEXT로 해도 충분할 거라 생각했지만 댓글 알림에는 imageUrl도 있고 해서, 혹시나 하는 마음에 MEDIUMTEXT를 적용했습니다.

 

 

 

결론

 

 

글 내용으로 보았을 때, 커디에서는 알림에 관한 검색, 변경이 없고 저장 용도로만 사용합니다.

즉, 조회만 존재하기 때문에 Json Type이 아닌 TEXT 타입으로 저장하기로 했습니다.

 

json type 결정 후 코드 변화

 

 

 

Event 객체 관리

 

 

public class CommentNotificationEvent {

  private static final String UPDATE_NOTIFICATION_COMMENT_TYPE = "COMMENT";

  private final Long receiverId;
  private final Long redirectId;
  private final LocalDateTime createdAt;
  private final String notificationType;
  private final String content;
  private final String writer;
  private final String writerImageUrl;

	// constructor ...
}

public class EventNotificationEvent {

  private static final String UPDATE_NOTIFICATION_EVENT_TYPE = "EVENT";

  private final Long receiverId;
  private final Long redirectId;
  private final String notificationType;
  private final LocalDateTime createdAt;
  private final String title;

	// constructor ...
}

 

 

 

 

public abstract class NotificationEvent {

  @JsonIgnore
  private final Long receiverId;
  @JsonIgnore
  private final Long redirectId;
  @JsonIgnore
  private final LocalDateTime createdAt;
  @JsonIgnore
  private final String notificationType;
}

public class EventNotificationEvent extends NotificationEvent {

  private static final String UPDATE_NOTIFICATION_EVENT_TYPE = "EVENT";

  private final String title;

  public EventNotificationEvent(
      final Long receiverId, final Long redirectId,
      final LocalDateTime createdAt, final String notificationType,
      final String title
  ) {
    super(receiverId, redirectId, createdAt, notificationType);
    this.title = title;
  }
}

public class CommentNotificationEvent extends NotificationEvent {

  private static final String UPDATE_NOTIFICATION_COMMENT_TYPE = "COMMENT";

  private final String content;
  private final String writer;
  private final String writerImageUrl;

  public CommentNotificationEvent(
      final Long receiverId, final Long redirectId,
      final LocalDateTime createdAt, final String notificationType,
      final String content, final String writer,
      final String writerImageUrl
  ) {
    super(receiverId, redirectId, createdAt, notificationType);
    this.content = content;
    this.writer = writer;
    this.writerImageUrl = writerImageUrl;
  }
}

 

 

NotificationEvent를 abstract로 만들어 둔 이유는 해당 클래스의 인스턴스 생성을 막기 위함입니다.

 

부모 클래스의 인스턴스 필드는 모든 알림에서 공통적으로 필요한 값이고,

json 데이터는 해당 알림이 특정하게 가지고 있는 데이터만을 저장하기로 했습니다.

 

 

EventListener 코드 변화

 

 

public void createCommentNotification(final CommentNotificationEvent commentNotificationEvent) {
  try {
    
        // Event 객체에서 값 꺼내기
        // Notification 생성 및 저장
        // FCM에 메시지 보내기

  } catch (JsonProcessingException e) {
    log.error("json 에러");
  } catch (Exception e) {
    log.error("파이어베이스 관련 에러, 알림 재요청 필요, {}", e.getMessage(), e);
  }
}

@Transactional(propagation = Propagation.REQUIRES_NEW)
@TransactionalEventListener
public void createEventNotification(final EventNotificationEvent eventNotificationEvent) {
	// 위 로직과 거의 동일
}

 

 

EventListener에서는 각 Event에 대해서 Listener가 있었습니다.

 

로직은 값 꺼내는 부분만 다르고, 모든 로직은 같았습니다.

추후에 알림이 추가될 때마다 Listener가 하나씩 더 생겨나게 됩니다.

 

이 부분이 딱히 불편하진 않았지만, Notification으로 추상화되어 있기 때문에 이를 파라미터로 받아서 할 수 있었습니다.

 

@Transactional(propagation = Propagation.REQUIRES_NEW)
@TransactionalEventListener
public void createNotification(final NotificationEvent notificationEvent) {
  try {
    final String jsonData = objectMapper.writeValueAsString(notificationEvent);

    final Notification notification = notificationRepository.save(
        new Notification(
            NotificationType.valueOf(notificationEvent.getNotificationType()),
            notificationEvent.getReceiverId(),
            notificationEvent.getRedirectId(),
            notificationEvent.getCreatedAt(),
            jsonData
        )
    );

    firebaseCloudMessageClient.sendMessageTo(
        notification,
        notification.getReceiverId()
    );

  } catch (JsonProcessingException e) {
    log.error("json 에러");
  } catch (Exception e) {
    log.error("파이어베이스 관련 에러, 알림 재요청 필요, {}", e.getMessage(), e);
  }
}

 

 

위에서 NotificationEvent가 가지고 있는 인스턴스 필드는 모든 알림에서 공통으로 가지고 있기 때문에,

jsonData에 저장되면 안 됩니다.

 

그래서 NotificationEvent에 @JsonIgnore를 추가해 줍니다.

 

ObjectMapper는 직렬화할 때, 실제 들어온 객체, 즉, 자식 타입의 값을 직렬화해 주기 때문에 

위와 같이 하나의 Listener 메서드를 통해 모든 알림 이벤트를 Listen 할 수 있습니다.

 

 

 

 

 

결론

 

만약 다른 알림이 추가된다면 어떻게 될까요?

 

1. 해당 알림 Event 객체 만들기

2. EventPublisher에서 해당 객체 publishing 하기

3. FCM에 메시지를 보내기 위한 Messgae Generator 만들기

- generator 도 추상화 되어있음

 

만 하면 됩니다.

 

 

이렇게 추상화를 하게 되면 많은 코드를 변경하지 않아도 기능을 추가할 수 있습니다.

그만큼 사이드 이펙트도 줄고, 문제 파악하는 시간도 줄어들 수 있다고 생각합니다.

 

 

같은 팀인 홍실이 쪽지 알림 기능을 구현할 때, 이미 추상화가 되어있어서 큰 어려움이 없었다고 했습니다.

 

하지만 나만 알고 있는 '추상화'는 좋지 않을 수 있기 때문에 이 또한 적절하게 해야겠다는 생강기 들었습니다.

 

 

해당 코드는 여기에서 보실 수 있습니다.

 

 

REFERENCES

 

 

JSON vs TEXT 당근 블로그

 

 

 

728x90