https://github.com/bellringstar/keepham/tree/master/BACKEND/src/main/java/com/ssafy/keepham/domain/chatroom

 

keepham/BACKEND/src/main/java/com/ssafy/keepham/domain/chatroom at master · bellringstar/keepham

Contribute to bellringstar/keepham development by creating an account on GitHub.

github.com

 

https://github.com/bellringstar/keepham-refact/tree/main/BACKEND/src/main/java/com/ssafy/keepham/domain/chatroom

 

keepham-refact/BACKEND/src/main/java/com/ssafy/keepham/domain/chatroom at main · bellringstar/keepham-refact

Contribute to bellringstar/keepham-refact development by creating an account on GitHub.

github.com

 

위에서 리팩토링 전의 전체 코드를 확인할 수 있습니다.

코드를 보면 아시겠지만 전체적으로 책임의 분리, 예외처리 일관성 부족, 동시성 이슈 등이 부족합니다.


채팅방을 만들었으니 이제는 채팅방을 조회하는 메서드들을 살펴보겠습니다.

관련 메서드는 openedRoom, findRoomById, findAllRoomByZipCode 이렇게 3가지가 존재합니다. 우선 openedRoom부터 개선해 보도록 하겠습니다.

openedRoom(전)

    public List<ChatRoomResponse> openedRoom(ChatRoomStatus status, int page, int pageSize) {
        Pageable pageable = PageRequest.of(page - 1, pageSize);
        Page<ChatRoom> chatRoomEntityPage = chatRoomRepository.findAllByStatusOrderByCreatedAtDesc(status, pageable);

        List<ChatRoom> chatRooms = chatRoomEntityPage.getContent();
        return chatRooms.stream().map(chatRoomConverter::toResponse)
                .map(it -> {
                    var currentNumber = chatRoomManager.getUserCountInChatRoom(it.getId());
                    it.setCurrentPeopleNumber(currentNumber);
                    return it;
                })
                .collect(Collectors.toList());
    }

페이지네이션을 통해 채팅방 목록을 반환해 주는 코드입니다.

채팅방의 상태, 페이지 그리고 페이지 크기를 파라미터로 받고 있습니다. 반환타입은 리스트네요.

그리고 채팅방에 현재 있는 사용자 수를 파악해 함께 보내주고 있습니다.

ChatRoom 엔티티는 현재 채팅방의 참가자 수를 필드로 가지고 있지 않습니다.

 

1. 왜 반환타입이 Page가 아닌 List인가

페이지네이션을 할 때 반환타입으로는 Slice, Page, List 이렇게 세 종류를 주로 사용합니다.

Slice는 데이터의 조각을 나타냅니다. 다시 말해 전체 데이터셋의 크기를 알 필요 없이 일부 데이터만 다룹니다. 따라서 추가 count 쿼리 없이 다음 페이지만 확인이 가능하다. 이는 내부적으로 limit + 1 조회를 통해 처리됩니다. 예를 들어 size가 20인 Slice를 요청하면 내부적으로는 21개의 요소를 조회하려고 시도합니다. 이렇기에 Slice는 전체 페이지를 알 필요 없이 다음 페이지의 존재 여부만 필요한 경우 사용하면 좋습니다.

 

여기서 Page는 Slice를 확장해 전체 데이터셋에 대한 추가 정보를 제공합니다. 내부적으로는 JPQL이나 SQL로 변환되어 실행되며 전체 데이터셋을 다루고 있기 때문에 count 쿼리가 별도로 실행됩니다.

List는 단순히 요청된 페이지에 해당하는 데이터만을 List 형태로 반환하는 경우입니다.
Pageable 객체를 사용해 offset과 limit를 계산하는 것은 위의 두 방식과 동일합니다. Slice와 비슷하지만 Slice는 다음 페이지의 존재 여부를 알 수 있는 반면 List는 불가능합니다.

이제 어떤 반환 타입을 사용해야 할지 결정해야 합니다.

페이지네이션은 방금 말한 offset 기반 방식들이 있고 커서기반 방식이 있습니다. 하지만 오프셋 기반 페이지네이션은 마지막 페이지를 구하기 위해 전체 개수를 알아야 하기 때문에 데이터가 많아질수록 부하가 커집니다.

또한 offset만큼 데이터가 읽고 버려지기 때문에 이 또한 자원 낭비로 이어집니다.

이를 보완하기 위해 커서기반 페이지네이션이 있습니다. 이는 특정 키(커서)를 기반으로 그 위치부터 개수를 받아오는 형태입니다. 즉 키를 기반으로 데이터 탐색 범위를 최소화합니다. 하지만 페이지 번호로 이동하는 형태의 UI 구현이 어렵다는 단점이 있습니다. 따라서 모바일 환경의 무한스크롤과 같은 페이지네이션 환경에서 어울립니다.

저는 무한스크롤보다는 게시판처럼 좌우 페이지를 번호를 통해 이동할 수 있는 UI를 생각하고 있습니다.

과거 구현시에는 아마 count 쿼리 때문에 성능상 문제가 있을 것이라 생각하고 List를 선택했던 것 같습니다. 하지만 채팅방 검색의 경우 열려있는 상태의 채팅방만 검색이 되기 때문에 데이터셋이 그렇게까지 크지 않을 것이라고 예상됩니다. 또한 비즈니스적으로 UI 구성을 번호로 이동하는 방식을 채택했기 때문에 Page를 통해 전체 게시글의 count를 파악해야만 합니다.

따라서 반환 타입은 Page로 변경할 예정입니다.

 

2. chatRoomManager.getUserCountInChatRoom

현재 이 메서드를 통해서 현재 채팅방의 사용자 수를 불러오고 있습니다.

    public Long getUserCountInChatRoom(Long roomId){
        Long size = redisTemplate.opsForSet().size("roomId" + String.valueOf(roomId));
        if (size != null) {
            return size;
        }
        getAllUser(roomId);
        return redisTemplate.opsForSet().size("roomId" + String.valueOf(roomId));
    }

    public Set<String> getAllUser(Long roomId){
        try{
            return redisTemplate.opsForSet().members("roomId" + String.valueOf(roomId));
        } catch (NullPointerException e){
            roomUserRepository.findAllByRoomIdAndStatus(roomId, ChatRoomUserStatus.NORMAL).stream()
                    .map(it -> {
                        var userNickname = it.getUserNickName();
                        redisTemplate.opsForSet().add("roomId" + String.valueOf(roomId),userNickname);
                        redisTemplate.expire(String.valueOf("roomId" + String.valueOf(roomId)), 3600*3, TimeUnit.SECONDS);
                        return null;
                    });
            return redisTemplate.opsForSet().members("roomId" + String.valueOf(roomId));
        }
    }
    

보시다시피 레디스에 채팅방의 현재 사용자 수를 캐싱해서 쓰고 있습니다. 만약 레디스에 없다면 db 조회를 해 캐싱을 하고 있습니다.

그런데 채팅방의 현재 인원수는 아주 빈번하게 바뀌는 정보입니다. 그런 데이터를 캐싱하면 데이터 일관성이 깨지기 딱 좋습니다. 오히려 캐시를 매 번 갱신한다고 성능에 악영향을 줄 수도 있습니다.

따라서 이러한 방식의 캐싱은 사용해서는 안됩니다.

openedRoom(후)

    public Page<ChatRoomResponse> getOpenedRooms(ChatRoomStatus status, Pageable pageable) {
        return chatRoomRepository.findAllDtoByStatus(status, pageable);
    }

사실 여기서 중요한 것은 해당 메서드보다는 chatRoomRepository의 findAllDtoByStatus입니다.

    @Query(value = """
             SELECT new com.ssafy.keepham.domain.chatroom.dto.ChatRoomResponse(
               cr.id, cr.title, cr.status,
               (SELECT COUNT(cru) FROM ChatRoomUser cru WHERE cru.chatRoom = cr AND cru.status = 'NORMAL'),
               cr.maxPeopleNumber,
               s.id, s.name,
               b.id,
               u.id, u.name,
               cr.createdAt, cr.closedAt,
               cr.locked, cr.extensionCount, cr.phase
            )
            FROM ChatRoom cr
            JOIN cr.store s
            JOIN cr.box b
            JOIN cr.superUser u
            WHERE cr.status = :status
            """,
            countQuery = """
                       SELECT COUNT(DISTINCT cr.id)
                       FROM ChatRoom cr
                       WHERE cr.status = :status
                    """)
    Page<ChatRoomResponse> findAllDtoByStatus(@Param("status") ChatRoomStatus status, Pageable pageable);

위와 같은 방식으로 한 번에 조인과 서브쿼리를 통해 필요한 데이터를 불러와 dto로 생성해 반환하도록 하였습니다.

이를 통해 현재 인원수에 대한 데이터 일관성을 유지할 수 있었습니다.

또한 단일 쿼리로 조회하여 네트워크 및 데이터베이스 접근 횟수를 줄일 수 있었습니다.

https://github.com/bellringstar/keepham/tree/master/BACKEND/src/main/java/com/ssafy/keepham/domain/chatroom

 

keepham/BACKEND/src/main/java/com/ssafy/keepham/domain/chatroom at master · bellringstar/keepham

Contribute to bellringstar/keepham development by creating an account on GitHub.

github.com

 

https://github.com/bellringstar/keepham-refact/tree/main/BACKEND/src/main/java/com/ssafy/keepham/domain/chatroom

 

keepham-refact/BACKEND/src/main/java/com/ssafy/keepham/domain/chatroom at main · bellringstar/keepham-refact

Contribute to bellringstar/keepham-refact development by creating an account on GitHub.

github.com

 

위에서 리팩토링 전의 전체 코드를 확인할 수 있습니다.

코드를 보면 아시겠지만 전체적으로 책임의 분리, 예외처리 일관성 부족, 동시성 이슈 등이 부족합니다.


엔티티가 정해졌으니 이제 서비스를 개선해보도록 하겠습니다.

서비스 개선에는 책임의 분리, 메서드 변경 등이 있지만 우선은 메서드들부터 하나씩 분석해 보고 개선하는 과정을 가져보겠습니다.

createRoom(전)

    @Transactional
    public ChatRoomResponse createRoom(ChatRoomRequest chatRoomRequest){
        var userInfo = userService.getLoginUserInfo();
        var userNickName = userInfo.getNickName();
        var entity = chatRoomConverter.toEntity(chatRoomRequest);
        var box = boxRepository.findFirstById(chatRoomRequest.getBoxId());
        if (box.isUsed()) {
            throw new ApiException(ErrorCode.BAD_REQUEST, "이미 사용중인 box입니다.");
        }
        box.setUsed(true);
        entity.setBox(box);
        return Optional.ofNullable(entity)
                .map(it -> {
                    it.setStatus(ChatRoomStatus.OPEN);
                    var newEntity = chatRoomRepository.save(it);
                    newEntity.setClosedAt(newEntity.getCreatedAt().plusHours(3));
                    newEntity.setSuperUserId(userNickName);
                    chatRoomManager.userJoin(newEntity.getId(), userService.getLoginUserInfo().getNickName());
                    return chatRoomConverter.toResponse(newEntity);
                })
                .orElseThrow(() -> new ApiException(ErrorCode.BAD_REQUEST));
    }

채팅방을 생성하는 메서드입니다. 쭉 살펴보죠.

우선 @Transactional 어노테이션이 있습니다. 해당 메소드에서 동작하는 작업들을 하나의 트랜잭션으로 묶고 있습니다. 전파 방식이나 격리 수준등의 별도 설정은 없는 디폴트 상태입니다.

@Transactional의 필요여부부터 따져보겠습니다. createRoom의 주요 작업은 다음과 같습니다.

  1. 사용자 조회
  2. Box 상태 조회 및 변경
  3. ChatRoom 엔티티 생성 및 저장
  4. chatRoomManager를 통해 사용자 참여

이 작업에 있어 Box의 상태 변경과 ChatRoom의 생성이 원자적으로 이루어져야 합니다.

또한 Box 업데이트와 ChatRoom 저장이라는 두 개의 별도 작업이 있습니다.

따라서 @Transactional 어노테이션이 필요한것으로 보입니다.

 

다음으로 입력값과 반환값입니다.

입력값으로 ChatRoom 생성을 위한 dto인 chatRoomRequest를 받고 있으며 반환값으로는 ChatRoom 생성의 결과인 ChatRoomResponse를 반환하고 있습니다.

여기서 고민점은 반환값입니다. ChatRoomResponse는 api response에 종속돼 있습니다. 하지만 저는 서비스계층은 프레젠테이션 계층과 독립적이어야 한다고 생각합니다.

따라서 ChatRoom 엔티티를 반환하는게 맞지 않을까 하는 고민이 있습니다. 그렇지만 ChatRoom 엔티티를 반환하게 되면 생기는 문제 또한 존재합니다.

프레젠테이션 계층에서 엔티티에 대한 의존이 생긴다는 것도 있지만 이 부분은 그렇게 큰 문제가 되지 않는다고 생각합니다.

더 큰 문제는 OSIV 문제입니다.

OSIV를 간단하게 설명하자면 영속성 컨텍스트의 생존 범위를 뷰 렌더링 시점까지 늘리는 기술입니다.

이를 통해 지연 로딩(Lazy Loading) 문제를 해결하고 컨트롤러와 뷰에서 영속성 객체를 편리하게 사용할 수 있도록 합니다.

그렇지만 영속성 컨텍스트가 오랜 시간 열려있기에 메모리 문제가 발생할 수 있습니다.

또한 데이터베이스 연결 시간이 늘어나기에 커넥션 문제 또한 발생할 수 있죠.

따라서 대부분의 경우 OSIV를 비활성화하게 됩니다.

이렇게 되면 프레젠테이션 계층에서 dto로 변환에 문제가 생길 수 있습니다.

종합적으로 판단한 결과 반환값을 dto인 ChatRoomResponse로 유지하는 게 맞다는 판단이 들었습니다.

 

이제 코드로 들어가 보겠습니다.

우선 setter를 많이 쓰고있는 부분은 모든 엔티티에서 setter를 없앨 예정이기에 수정을 할 예정입니다.

진짜 고민은 서비스에서 다른 서비스를 의존하고 있다는 것입니다.

예를 들어 현재 ChatRoomService는 UserService를 의존하고 있습니다.

이는 높은 결합도를 갖게 되므로 UserService가 변경되면 ChatRoomService도 영향을 받게 됩니다.

어떤 방식으로 개선하는게 좋을까요?

  1. repository 의존예를 들어 단순한 조회가 아니라 일종의 비즈니스 로직이 들어간 경우라면? 코드가 중복이 발생하게 되고 유지보수성이 떨어지게 됩니다.
  2. 그렇다면 UserRepository를 의존하는 게 맞을까? 이 역시도 아니라고 생각합니다.
  3. controller에서 다양한 서비스를 의존 이 방법은 하나의 트랜잭션으로 묶을 수 없기에 최악이라고 생각한다.
  4. 여러 Service를 묶는 하나의 Service만들기 일종의 Facade Pattern이다. 하지만 이는 복잡성을 불러일으킨다.

기존의 방식대로 Service에서 Service를 의존해도 문제는 없습니다.

대신 다른 도메인도 관리하게 돼 책임이 불분명해지는 문제와 순환 참조가 걱정일 뿐이죠.

그래도 순환참조는 서비스를 쪼개서 순환 참조가 되지 않도록 개선할 수도 있습니다.

Facade 방식은 별도의 관리할 객체가 늘어난다는 복잡성이 문제입니다.

결론적으로는 의존성을 줄이고 결합도를 낮추는 설계를 선호하기 때문에 Facade 패턴을 도입해 개선을 하도록 하겠습니다.

이렇게 되면 위에서 서비스에서 dto를 반환하기로 한 결정이 좀 수정이 돼야 합니다.

하위 서비스에서는 entity를 반환하고 Facade에서 dto로 변환해 반환하는 방식으로 처리하도록 하겠습니다.

이렇게 하니 대수술이 필요해졌습니다.

특히 UserService 등 제가 구현하지 않은 부분들의 수정도 필요해졌습니다. 우선은 임시로 구현해 놓도록 하겠습니다.

createRoom(후)

    @Transactional
    public ChatRoom createRoom(ChatRoomRequest chatRoomRequest, Box box, User user, Store store) {
        ChatRoom chatRoom = ChatRoom.builder()
                .title(chatRoomRequest.getTitle())
                .store(store)
                .box(box)
                .maxPeopleNumber(chatRoomRequest.getMaxPeopleNumber())
                .superUser(user)
                .locked(chatRoomRequest.isLocked())
                .password(chatRoomRequest.getPassword())
                .build();

        return chatRoomRepository.save(chatRoom);
    }

createRoom 메서드는 단순하게 chatRoom을 만들어 저장하는 역할만 하게 변경됐습니다.

@Service
@RequiredArgsConstructor
@Slf4j
public class ChatRoomFacade {

    private final ChatRoomConverter chatRoomConverter;
    private final ChatRoomManager chatRoomManager;
    private final ChatRoomService chatRoomService;
    private final BoxService boxService;
    private final StoreService storeService;

    @Transactional
    public ChatRoomResponse createRoom(ChatRoomRequest chatRoomRequest, User user) {
        Box box = boxService.getValidBox(chatRoomRequest.getBoxId());
        Store store = storeService.getAvailableStore(chatRoomRequest.getStoreId());

        ChatRoom chatRoom = chatRoomService.createRoom(chatRoomRequest, box, user, store);
        box.markAsUsed();
        return chatRoomConverter.toResponse(chatRoom);
    }
}

그리고 다른 서비스를 의존하는 Facade 서비스가 생성됐습니다.

이를 통해 서비스 간의 의존성을 줄일 수 있었습니다.

 

이제 남은 건 동시성 문제입니다.

현재 Box의 사용 여부를 판단 한 다음 채팅방을 생성하고 Box의 사용여부를 변경하고 있습니다.

이 부분이 동시성 이슈가 있을 수 있는 부분입니다.

예를 들어 여러 사용자가 동시에 동일한 boxId로 채팅방 생성 요청을 보냈을 때, 두 개 이상의 스레드가 동시에 boxService.getValidBox(boxId)를 호출하게 됩니다.

이때 만약 동시에 호출된 경우 둘 다 Box가 사용 중이 아니라고 판단할 수 있고 이로 인해 동일한 Box가 여러 ChatRoom에 할당되는 문제가 발생하게 됩니다.

 

이를 해결하기 위해 락을 사용할 것입니다.

이제 비관적락을 쓸 건지 낙관적락을 쓸건지 선택해야 합니다.

테스트를 해보는 게 제일 좋지만 현재 환경상 그러기 힘드므로 이론적으로 선택을 하겠습니다.

채팅방을 생성하면서 같은 박스를 선택하는 경우는 충돌이 빈번하게 발생할 것으로 예상됩니다.

@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT b FROM Box b WHERE b.id = :id")
Optional<Box> findByIdForUpdate(@Param("id") Long id);

따라서 비관적 락을 적용해 Box를 조회하고 사용 가능 여부를 확인하는 과정을 원자적으로 처리하도록 하였습니다.

https://github.com/bellringstar/keepham/tree/master/BACKEND/src/main/java/com/ssafy/keepham/domain/chatroom

 

keepham/BACKEND/src/main/java/com/ssafy/keepham/domain/chatroom at master · bellringstar/keepham

Contribute to bellringstar/keepham development by creating an account on GitHub.

github.com

 

https://github.com/bellringstar/keepham-refact/tree/main/BACKEND/src/main/java/com/ssafy/keepham/domain/chatroom

 

keepham-refact/BACKEND/src/main/java/com/ssafy/keepham/domain/chatroom at main · bellringstar/keepham-refact

Contribute to bellringstar/keepham-refact development by creating an account on GitHub.

github.com

 

위에서 리팩토링 전의 전체 코드를 확인할 수 있습니다.

코드를 보면 아시겠지만 전체적으로 책임의 분리, 예외처리 일관성 부족, 동시성 이슈 등이 부족합니다.

RoomUserEntity개선 - 전

이번에는 RoomUserEntity부터 보겠습니다.

@Document(collection = "chatroom")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class RoomUserEntity{

    @Id
    private String id;
    private Long roomId;
    private String userNickName;
    @Enumerated(value = EnumType.STRING)
    private RoomUserStatus status;
}

엔티티가 아니라 Document 입니다. 우선 어째서 MongoDB에 이 Document를 저장하기로 했을지 생각해 보겠습니다.

  1. 빈번한 쓰기 및 읽기 작업
    해당 Document는 Chatroom의 사용자를 관리하고 있는 것으로 보입니다. 이는 매우 빈번한 삽입, 수정 이벤트입니다. 이러한 동작은 RDB에서 성능 병목을 일으킬 수 있습니다.
    반면 MongoDB는 Document 지향 데이터베이스로, 스키마가 유연하고 인덱스 유지에 대한 오버헤드가 적어 빈번한 쓰기 작업에 유리합니다.
    게다가 샤딩을 통해 수평적 확장이 용이하므로, 사용자 수와 채팅방 수가 증가해도 성능 저하 없이 확장할 수 있습니다.

이런 확실한 장점이 있는 것은 알겠습니다. 그런데 과연 도입하는 게 맞는 걸까요?

MongoDB를 도입하는 데서 발생하는 문제 또한 분명 존재합니다.

  1. 분산된 DB
    MySQL과 MongoDB라는 두 개의 DB로 분리돼 관리의 복잡성이 증가합니다.
  2. 실시간성과 데이터 일관성
    MySQL의 Chatroom 테이블에는 해당 Chatroom의 최대 인원이 존재합니다. 그렇다면 해당 Chatroom의 최대 인원을 확인하고 MongoDB에서 해당 Chatroom의 유효한 사용자를 카운트한 다음 입장 로직이 수행돼야 합니다. 이를 분산된 두 DB에서 구현하려면 별도의 동시성 제어 로직이 필요해집니다.

RoomUserEntity개선 - 후

@Entity
@Table(name = "chatroom_user")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class ChatRoomUser extends BaseEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "chatroom_id", nullable = false)
    private ChatRoom chatRoom;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id", nullable = false)
    private User user;

    @Enumerated(value = EnumType.STRING)
    @Column(nullable = false)
    private ChatRoomUserStatus status;

    @Builder
    public ChatRoomUser(ChatRoom chatRoom, User user) {
        this.chatRoom = Objects.requireNonNull(chatRoom);
        this.user = Objects.requireNonNull(user);
        this.status = ChatRoomUserStatus.NORMAL;
    }
}

@RequiredArgsConstructor
public enum ChatRoomUserStatus {

    NORMAL("정상"),
    KICKED("추방당한 유저"),
    EXIT("퇴장");

    private final String description;
}

 

최종 저의 판단은 해당 데이터를 MongoDB가 아니라 MySQL을 통해 관리하는 게 맞다는 것입니다.

쓰기 작업의 성능을 일부 포기하더라도, 데이터의 일관성과 무결성을 확보하는 것이 더 중요하다고 판단했습니다.

따라서 해당 Document를 제거하고 ChatRoomUser라는 별도의 엔티티를 만들도록 하겠습니다.

별도 엔티티를 만드는 이유는 Chatroom과 User가 ManyToMany의 관계이기에 조인 테이블 역할을 하기 위해서입니다. 또한 추가로 status를 칼럼으로 넣어 추가적인 정보를 관리할 수 있습니다.

 

여기서 고민인 부분이 새롭게 생깁니다. 바로 ChatRoom 엔티티에 양방향 연관관계를 설정하느냐의 문제입니다.

개인적으로는 양방향 연관관계는 실수의 여지가 늘어난다고 생각합니다.

예를 들어 연관관계의 주인 즉 외래키를 가지고 있지 않은 곳에서 작업을 해도 DB에는 반영이 안 되기 때문입니다.

그래서 보통은 반드시 필요하다고 생각되는 부분만 사용하며 편의 메서드를 만들어 실수의 여지를 줄이고는 합니다. 여기서도 마찬가지로 ChatRoom에서 ChatRoomUser를 컬렉션으로 관리하면 복잡성이 오히려 늘어나는 것 같아 관련 로직은 별도의 비즈니스 로직이나 쿼리를 사용해 처리하도록 하겠습니다.

https://github.com/bellringstar/keepham/tree/master/BACKEND/src/main/java/com/ssafy/keepham/domain/chatroom

 

keepham/BACKEND/src/main/java/com/ssafy/keepham/domain/chatroom at master · bellringstar/keepham

Contribute to bellringstar/keepham development by creating an account on GitHub.

github.com

keepham-refact/BACKEND/src/main/java/com/ssafy/keepham/domain/chatroom at main · bellringstar/keepham-refact (github.com)

 

keepham-refact/BACKEND/src/main/java/com/ssafy/keepham/domain/chatroom at main · bellringstar/keepham-refact

Contribute to bellringstar/keepham-refact development by creating an account on GitHub.

github.com

 

위에서 리팩토링 전/후의 전체 코드를 확인할 수 있습니다.

코드를 보면 아시겠지만 전체적으로 책임의 분리, 예외처리 일관성 부족, 동시성 이슈 등이 부족합니다.

ChatRoomEntity 개선 - 전

우선은 ChatRoomEntity부터 보겠습니다.

@Entity
@Table(name = "chat_room")
@Getter
@Setter
@ToString(callSuper = true)
@NoArgsConstructor
@AllArgsConstructor
@SuperBuilder
@Builder
public class ChatRoomEntity extends BaseEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    @Column(length = 255, nullable = false, unique = true)
    private String title;
    @Enumerated(value = EnumType.STRING)
    @Column(nullable = false)
    private ChatRoomStatus status;
    @Column(nullable = false)
    private Long storeId;
    @OneToOne
    @JoinColumn(name = "box_id")
    private Box box;
    private Integer extensionNumber;
    private int maxPeopleNumber;
    private String superUserId;
    @Column(columnDefinition = "boolean default 0", nullable = false)
    private boolean locked;
    @Column(length = 255, nullable = false)
    private String password;
    private LocalDateTime closedAt;
    private int step;
}
  1. 우선 저 많은 롬복 어노테이션에 대해 하나하나 따져봅시다.
    1. @Entity JPA 엔티티임을 명시하는 데 필요한 어노테이션입니다.

    2. @Table 데이터베이스 테이블 이름을 지정하는 데 사용합니다. 없다면 chat_room_entity라는 이름으로 테이블이 생성됐을 것입니다. 오히려 클래스명을 Chatroom으로 변경하는 것이 옳다고 보입니다. 하지만 이는 팀 컨벤션에 따라 다를 수 있습니다.

    3. @Getter 모든 필드에 대한 getter 메서드를 생성해 줍니다. 개인적으로는 상태가 외부에 노출되기 때문에 반드시 필요한 필드에만 적용하는 게 맞다고 생각되지만 개발의 편의성을 위해 유지하는 것도 괜찮다고 봅니다.

    4. @Setter 제거 1순위입니다. 모든 필드에 대해 setter를 생성해 객체의 불변성을 해칩니다. 또한 이로 인해 사이드 이펙트가 발생할 수 있습니다. setter 보다는 보다 의미 있는 도메인 로직을 추가하는 것이 옳다고 생각됩니다.

    5. @ToString(callSuper=true) 해당 어노테이션은 ToString() 메서드를 자동으로 생성해 줍니다. callSuper 옵션을 통해 상위 클래스의 필드값 여기서는 created_at과 updated_at을 함께 출력하도록 설정했습니다. @ToString 어노테이션은 항상 순환 참조를 조심해야 합니다. 필요한 필드만 포함하도록 수정하는 것이 옳다고 생각됩니다.

    6. @NoArgsConstructor 매개변수가 없는 기본생성자를 생성해줍니다. JPA는 리플렉션을 사용해 엔티티 객체를 생성하기 때문에 필수적으로 필요하지만 과도한 노출을 제어하기 위해 접근 권한을 protected로 제한하는 것이 옳다고 생각됩니다.

    7. @AllArgsConstructor 부적절한 어노테이션입니다. 모든 필드를 매개변수로 가지는 생성자를 생성해 줍니다. 모든 필드를 매개변수로 받게 되면 객체 생성 시 오류 가능성을 높입니다. 따라서 제거하고 필요한 필드만 받는 생성자를 명시적으로 정의하는 게 옳다고 생각됩니다.

    8. @SuperBuilder, @Builder 부적절합니다. 모든 필드에 대해 빌더 메서드를 생성할 필요는 없습니다. 또한 @SuperBuilder를 쓸 이유조차 없습니다. 상위 클래스인 BaseEntity를 매개변수로 받으면 안 됩니다. 이 클래스의 필드들은 Auditing을 통해 자동으로 생성되고 있습니다. @Builder의 경우 @SuperBuilder와 역할이 중복됩니다.
  2. 다음으로 필드들을 보겠습니다.
    1. storeId와 Box 둘 다 별개의 엔티티와 관계를 맺는 일종의 외래키 포지션의 필드입니다. 하지만 어떤 것은 Long 타입이고 어떤 것은 객체를 통해 관계를 맺고 있습니다.

    2. 용도가 불분명한 필드 extensionNumber, step 등의 필드가 어떤 역할을 하는 필드인지 불분명합니다. 보다 명확한 네이밍이 필요하다고 생각됩니다.

ChatRoomEntity 개선

@Entity
@Table(name = "chatroom")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@ToString(exclude = {"store", "box"})
public class ChatRoom extends BaseEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(length = 255, nullable = false, unique = true)
    private String title;

    @Enumerated(value = EnumType.STRING)
    @Column(nullable = false)
    private ChatRoomStatus status;

    @Column(nullable = false)
    @OneToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "store_id")
    private Store store;

    @Column(nullable = false)
    @OneToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "box_id")
    private Box box;

    @Column(nullable = false)
    @OneToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_user_id")
    private User superUser;

    private int extensionCount;

    @Column(nullable = false)
    private int maxPeopleNumber;

    @Column(nullable = false)
    private boolean locked;

    @Column(length = 64, nullable = false)
    private String password;

    @Column(length = 16, nullable = false)
    private String salt;

    private LocalDateTime closedAt;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    private RoomPhase phase;

    @Builder
    private ChatRoom(String title, Store store, Box box, int maxPeopleNumber, User superUser, boolean locked, String password) {
        this.title = Objects.requireNonNull(title, "제목은 null일 수 없습니다.");
        this.store = Objects.requireNonNull(store, "store는 null일 수 없습니다.");
        this.box = Objects.requireNonNull(box, "Box는 null일 수 없습니다.");
        this.maxPeopleNumber = validateMaxPeopleNumber(maxPeopleNumber);
        this.superUser = Objects.requireNonNull(superUser, "Super user는 null일 수 없습니다.");
        this.locked = locked;
        this.password = locked ? Objects.requireNonNull(password, "비밀방에 password는 필수입니다.") : "";
        setPassword(password);
        this.status = ChatRoomStatus.OPEN;
        this.extensionCount = 0;
        this.phase = RoomPhase.CREATED;
    }

    public void setPassword(String password) {
        this.salt = generateSalt();
        this.password = hashPassword(password, this.salt);
    }

    public boolean checkPassword(String inputPassword) {
        String hashedInput = hashPassword(inputPassword, this.salt);
        return this.password.equals(hashedInput);
    }

    public void close() {
        if (this.status != ChatRoomStatus.OPEN) {
            throw new ApiException(ChatRoomError.BAD_REQUEST, "열려있는 채팅방만 닫을 수 있습니다.");
        }
        this.status = ChatRoomStatus.CLOSE;
        this.closedAt = LocalDateTime.now();
    }

    public void extendTime(long hours) {
        if (this.extensionCount >= 3) {
            throw new ApiException(ChatRoomError.BAD_REQUEST, "3회 이상 연장할 수 없습니다.");
        }
        this.closedAt = this.closedAt.plusHours(hours);
        this.extensionCount++;
    }

    public void changeSuperUser(User newSuperUser) {
        this.superUser = Objects.requireNonNull(newSuperUser, "Super user ID는 null일 수 없습니다.");
    }

    public void updatePhase(RoomPhase newPhase) {
        validatePhaseTransition(this.phase, newPhase);
        this.phase = newPhase;
    }

    private void validatePhaseTransition(RoomPhase currentPhase, RoomPhase newPhase) {
        if (newPhase.getStep() <= currentPhase.getStep()) {
            throw new ApiException(ChatRoomError.BAD_REQUEST, currentPhase + "에서 " + newPhase + "는 변경할 수 없습니다.");
        }
    }

    private int validateMaxPeopleNumber(int maxPeopleNumber) {
        if (maxPeopleNumber < 1 || maxPeopleNumber > 12) {
            throw new ApiException(ChatRoomError.BAD_REQUEST, "최대 인원은 1 ~ 12 입니다");
        }
        return maxPeopleNumber;
    }

    private String generateSalt() {
        SecureRandom random = new SecureRandom();
        byte[] salt = new byte[16];
        random.nextBytes(salt);
        return Base64.getEncoder().encodeToString(salt);
    }

    private String hashPassword(String password, String salt) {
        try {
            MessageDigest md = MessageDigest.getInstance("SHA-256");
            md.update(salt.getBytes());
            byte[] hashedPassword = md.digest(password.getBytes());
            return Base64.getEncoder().encodeToString(hashedPassword);
        } catch (NoSuchAlgorithmException e) {
            throw new RuntimeException("Failed to hash password", e);
        }
    }
}

위에서 언급됐던 문제들을 개선했습니다.

 

개선하며 고민한 점은 다음과 같습니다.

  1. 도메인 로직 추가로 인한 동시성 이슈
    예를 들어 extendTime 메서드의 경우 확인하고 증가시키고 있기 때문에 경쟁 조건이 발생할 수 있습니다.
    처음에는 비즈니스 제약 상 superUser만 해당 요청들을 할 수 있기 때문에 동시성 이슈의 발생 가능성이 낮다고 생각했습니다.
    그렇지만 만약 superUser가 여러 탭을 열어서 동시에 요청을 하는 경우, 트랜잭션 간의 경합 등의 원인으로 이슈가 발생할 수 있다고 판단했습니다.
    구현이 간단하며 도메인 로직들의 충돌 발생 확률이 낮다고 판단했기에 낙관적 락을 선택했습니다.

  2. superUserId 필드를 엔티티로 연관관계를 맺는게 맞는가
    기존의 String으로 사용자의 로그인ID를 가지고 있는 방식과 연관관계를 맺어 외래키로 userId를 가지고 있는 방식 사이에서의 고민이었습니다.
    로그인ID를 가지고 있는 경우에는 별도의 Join이 필요 없다는 장점이 있습니다. 또한 로그인 ID는 비즈니스 정책상 변경이 일어나지 않는 칼럼입니다.
    따라서 비정규화에서 오는 단점 또한 적다고 판단됩니다.
    하지만 만약 api에서 로그인 ID 이외의 사용자 정보가 함께 반환돼야 한다면 별도의 join이 필요하고 이는 pk가 아닌 별도의 로그인 ID 인덱스를 통해서 조인이 발생하게 됩니다.
    로그인 ID는 문자열 인덱스이기 때문에 pk에 비해 조금의 성능 저하가 있을 것으로 보입니다.
    User 엔티티를 @OneToOne으로 연관관계를 맺게 된다면 보다 손쉽게 User 정보에 접근할 수 있다는 장점이 있지만 항상 Join이 발생한다는 문제가 있습니다.

    최종 결정은 엔티티 연관 관계 방식을 선택했습니다.
    이유로는 로그인 ID라는 부분 때문입니다. 사용자의 로그인 ID를 외부로 노출시키고 싶지 않습니다. 따라서 주로 화면에는 닉네임으로 표시가 될 것입니다.
    그러면 어차피 로그인 ID를 가지고 있더라도 별도의 조인이 필요해집니다.
    그렇다면 닉네임을 가지고 있으면 되지 않느냐 생각하실 수 있습니다.
    하지만 닉네임은 변경이 가능한 칼럼입니다. 따라서 유저가 자신의 닉네임을 변경하게 되면 일관성을 위해 방장으로 있는 채팅방의 칼럼의 값도 변경해줘야 합니다.
    또한 사용자의 다른 정보가 필요할 수도 있는데 결국은 Join이 발생하는 건 똑같습니다.
    따라서 확장성을 위해 연관 관계 방식을 선택했습니다.

  3.  채팅방 password의 암호화 정도
    암호화도 비용입니다. 채팅방의 password가 그 비용을 써야 할 만큼 중요한지에 대해 고민했습니다.
    채팅방 암호는 민감도가 상대적으로 낮습니다. 유출되면 새로 들어오는 해당 유저를 추방하거나 방을 새로 파면되니까요.
    또한 채팅방은 오래 유지되지 않기 때문에 db에서 과거의 password 혹은 현재 채팅방의 password 유출된다 하더라도 큰 문제가 없을 것으로 생각합니다.
    그렇다 하더라도 조금의 영향이 있을 수 있으니 최소한의 암호화를 적용하기로 결정했습니다. 암호화라기보다는 해싱이 정확하겠네요.
    해싱 방식으로는 SHA-256 해시 함수와 salt를 사용해 처리하였습니다. 완전한 암호화는 아니지만 원본을 그대로 저장하는 것보다는 낫다고 판단했습니다.

 

bellringstar/keepham (github.com)

 

GitHub - bellringstar/keepham

Contribute to bellringstar/keepham development by creating an account on GitHub.

github.com

 

이 프로젝트는 제가 처음으로 진행한 팀 프로젝트였습니다. 사실 그 전까지는 개인 프로젝트조차 해본 적이 없었으니, 말 그대로 저에게는 첫 프로젝트였습니다. 개발자로 진로를 바꾼 지 반년 동안 파이썬과 알고리즘을 공부해 왔는데, 이 프로젝트에서는 갑작스레 자바 백엔드를 맡게 되었습니다. 자바도 스프링도 모르는데! 그런 상황 속에서 어떻게든 프로젝트를 완성해냈습니다. 하지만 구현에만 급급했던 탓에, 코드의 상태는 처참할 수밖에 없었습니다.

이제는 현재의 내가 그때의 나를 되돌아보며 반성하는 시간을 가져보려 합니다. 당시에 내가 왜 그렇게 구현했는지 그 이유를 생각해 보고, 지금의 내가 그 부분을 어떻게 리팩토링할 수 있을지 고민해 보려 합니다.

우선은 제가 직접 맡았던 파트를 중심으로 리팩토링을 진행할 계획입니다. 그리고 더 나아가, 시간이 된다면 다른 팀원들이 맡았던 부분도 내 관점에서 리팩토링해 보고자 합니다. 팀의 결과물인데 팀원들이 작성한 코드를 내 맘대로 수정하는 거 같아 미안한 마음이 듭니다. 하지만 이는 단순히 그들의 코드를 비판하려는 것이 아닙니다. 그들이 왜 그런 방식으로 구현했는지 이해해 보고, 내가 그 부분을 어떻게 구현할 수 있을지 나의 관점을 제시해 보는 것이 이 작업의 목적입니다.

우선은 제가 담당한 업무들을 나열해보고 이거를 목차로 삼아 진행해 보겠습니다.

* 채팅방API 서버 구현
* Stomp Over WebSocket을 이용한 실시간 채팅 시스템 개발
* Kafka를 활용한 메시징 시스템 구축
* MongoDB를 이용한 채팅 내역 관리 시스템 개발

지금 봐도 그냥 어처구니가 없습니다. 백엔드 개발이 처음인 아니 스프링 아니 더 나아가 자바가 처음인 사람이 담당해서 구현한 기능들이 WebSocket, Kafka, MongoDB ? 일단은 동작하는 기능을 구현한 과거의 저를 칭찬해주고 싶습니다.

아예 처음부터 설계해서 그냥 바닥부터 코드를 다시 작성하는 게 빠를 수도 있겠다는 생각이 듭니다. 하지만 레거시를 리팩토링 한다는 생각으로 최대한 기존의 코드를 기반으로 해보겠습니다.

인프라, 아키텍처, api는 변경하지 않습니다. 인프라는 가정을 한 뒤 확장성 있는 서버를 고려해 비즈니스 로직위주의 리팩토링이 될 것입니다.

+ Recent posts