현재 진행하고 있는 프로젝트의 멀티 모듈화를 마치고 해당 글을 작성하여 글의 내용에 누락이 있을 수 있다는 점에 대해 양해를 부탁드립니다. 

프로젝트 설명

먼저 프로젝트에 관해 간단하게 설명하자면 8to10 이라는 이름의 갓생을 살고자하는 P 성향의 사람에게 세우기 귀찮은 계획을 세워주며 일정 수행을 어느정도 강제하기 위해 진행한 일정관리 프로젝트이다.
프로젝트를 진행할 때 기업이 운영하는 정도의 대규모 어플리케이션 정도로 설계하는 것은 불가능 하겠지만 사용자가 많은 어플리케이션의 상황을 어느정도 가정하고 설계하려고 했다. 그렇기 때문에 설계적 관점과 유지보수 측면에서의 관점 둘다에 대해서 고려할 필요가 있었다.

아래는 해당 프로젝트의 아키텍처 자료이다.

요약하자면 1개의 클러스터 3개의 pod로 AutoScaling되는 분산서버, 단일 db, 단일 캐시로 구성된 아키텍처이다.

멀티 모듈화의 이유

  1. Scheduling 서버의 분리
    서비스의 기능 중 정해진 시간에 사용자에게 알람으로 하나의 피드백 메시지를 날려주는 스케쥴링 서비스를 필요로 한다.
    하지만 위 구조에서 3개의 서버가 중복된 피드백 메시지를 동일 사용자에게 전송하는 문제가 있었다. 그렇기 때문에 Scheduling 서버에 대한 분리가 필요했다.
  2. 멀티 모듈화로 얻을 수 있는 추가적인 이점들
    • 테스트 코드 구현시 테스트 환경의 분리
    • 변경된 모듈만 개별적으로 빌드 및 배포 가능
    • 등등

멀티 모듈화를 생각하게 되었던 가장 큰 이유는 Scheduling 서버의 분리 때문이었다.  Scheduling 서버의 분리 이외에도 멀티 모듈화를 진행하면서 얻을 수 있는 부수적인 장점도 많았다. 모듈화를 진행하는 과정에서 여러가지에 대해 고민을 하면서 단순히 기존의 코드를 분리하는 것이 아니라 도메인객체와 엔티티 객체를 분리하고 도메인 객체를 바라보는 관점과 레이어를 바라보는 관점 그리고 테스트 코드를 다시 작성하고 기존의 코드를 수정하는 과정에서  많은 부분을 배울 수 있었다. 그렇기에 나의 고민과 진행 과정을 시리즈 글로 남기려고 하고 이 글을 보는 사람들과 다양한 생각을 나눌수 있으면 좋을 것 같다.

이번 글에서는 내가 했던 gradle 세팅에 대해 작성하고자 한다.

Gradle 세팅 및 모듈 추가

기존 gradle 구성

build.gradle

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-aop'
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-validation'
    implementation 'org.springframework.boot:spring-boot-starter-web'

    implementation 'org.springframework.boot:spring-boot-starter-actuator'

    //redis
    implementation 'org.springframework.boot:spring-boot-starter-data-redis'

    // spring security
    implementation 'org.springframework.boot:spring-boot-starter-security'

    implementation 'com.mysql:mysql-connector-j'
    // jwt
    implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
    runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
    runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'

    //json
    implementation 'org.json:json:20231013'

    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'

    // P6Spy 의존성 추가 (개발 서버용)
    implementation 'p6spy:p6spy:3.9.1'

    //Querydsl 추가
    implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta'
    annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jakarta"
    annotationProcessor("jakarta.persistence:jakarta.persistence-api")
    annotationProcessor("jakarta.annotation:jakarta.annotation-api")

    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'org.springframework.security:spring-security-test'
    testImplementation 'com.h2database:h2'
    testImplementation 'com.github.codemonstur:embedded-redis:1.4.3'

    //parametererized test
    testImplementation 'org.junit.jupiter:junit-jupiter-params'
    testCompileOnly 'org.projectlombok:lombok'
    testAnnotationProcessor 'org.projectlombok:lombok'
}

 

위 gradle 세팅을 보면 여러 역할을 하는 implementation 구문들이 쌓여있는 것을 확인할 수 있다. 이는 해당 모듈이 정확히 어떤일을 하고 있는지 파악하기 힘들게 만들고, 하나의 기능에 대한 변경이 다른 코드에 대해 영향을 미칠 수 있다는 것을 뜻한다. 

settings.gradle

    rootProject.name = 'eighttoten'

패키지 구조

기존의 패키지 구조는 다음과 같다.

현재 패키지의 구조를 보면 achivement라는 도메인 관련 패키지 하위에 모든 레이어의 코드들이 혼재되어 있는 상태이지만 data access와 관련된 코드들은 data access 와 관련된 모듈안에 모여있는 것이, 다른 계층도 마찬가지로 관련된 기능들이 같은 모듈안에 모여있는 것이 더 응집도 측면에서 잘 응집되어 있는 것이 아닐까 라는 생각이 든다. 이 기능들을 관심사에 맞게 차근차근 하나씩 분리하고자 한다.

아래는 이를 위해 공통적으로 해야할 gradle 세팅들에한 내용이다.

1. 기존의 settings.gradle 상위 폴더로 빼주고 다음과 같이 변경했다.

`rootProject.name = 'eighttoten'`

include(  
"8to10_backend"  
)

위 설정을 통해 프로젝트에서 8to10_backend 모듈을 include 할 수 있게 된다. 

2. 기존의 build.gradle 파일을 상위 폴더에 복사 붙여넣기 한다.

해당 파일을 상위에 생성하는 이유는 공통 implementation이나 프로젝트 세팅을 위해서 이다. 아직은 하나의 모듈만 있기때문에 복사 붙여넣기 했다. 

3. 모듈들의 공통 버전 관리를 위해 gradle.properties 파일을 상위 폴더에 생성한다.

이렇게 gradle.properties 파일을 생성함으로써 각 버전들에 대한 변수를 정의하고 하나의 파일에서 모듈들의 버전에 대해 통합적으로 관리할 수 있다.

### Application Version ###
applicationVersion=0.0.1

### Project configs ###
projectGroup=online.8to10

javaVersion=17

springBootVersion=3.2.5
springDependencyManagementVersion=1.1.4

4. settings.gradle 수정

settings.gradle 에서 서브 모듈들의 플러그인 버전을 관리 한다. 이렇게 중앙에서 플러그인을 관리함으로써 모듈들의 버전 충돌을 방지하고 유지보수성을 늘릴 수 있다.

pluginManagement {
    String springBootVersion = settings.getProperty("springBootVersion")
    String springDependencyManagementVersion = settings.getProperty("springDependencyManagementVersion")

    resolutionStrategy {
        eachPlugin {
            if (requested.id.id == "org.springframework.boot") {
                useVersion(springBootVersion)
            } else if (requested.id.id == "io.spring.dependency-management") {
                useVersion(springDependencyManagementVersion)
            }
        }
    }
}

5. 상위 폴더(아까 복사 붙여넣기 했던) build.gradle 수정

plugins {
    id 'java'
    id 'org.springframework.boot' apply false
    id 'io.spring.dependency-management' apply false
}

configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
}

allprojects {
    java.sourceCompatibility = javaVersion
    group = projectGroup
    version = applicationVersion

    repositories {
        mavenCentral()
    }
}

subprojects {
    apply plugin: 'java'
    apply plugin: 'org.springframework.boot'
    apply plugin: 'io.spring.dependency-management'

    dependencies {
        implementation 'org.springframework.boot:spring-boot-starter-aop'
        implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
        implementation 'org.springframework.boot:spring-boot-starter-validation'
        implementation 'org.springframework.boot:spring-boot-starter-web'

        implementation 'org.springframework.boot:spring-boot-starter-actuator'

        //redis
        implementation 'org.springframework.boot:spring-boot-starter-data-redis'

        // spring security
        implementation 'org.springframework.boot:spring-boot-starter-security'

        implementation 'com.mysql:mysql-connector-j'
        // jwt
        implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
        runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
        runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'

        //json
        implementation 'org.json:json:20231013'

        compileOnly 'org.projectlombok:lombok'
        annotationProcessor 'org.projectlombok:lombok'

        // P6Spy 의존성 추가 (개발 서버용)
        implementation 'p6spy:p6spy:3.9.1'

        //Querydsl 추가
        implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta'
        annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jakarta"
        annotationProcessor("jakarta.persistence:jakarta.persistence-api")
        annotationProcessor("jakarta.annotation:jakarta.annotation-api")

        testImplementation 'org.springframework.boot:spring-boot-starter-test'
        testImplementation 'org.springframework.security:spring-security-test'
        testImplementation 'com.h2database:h2'
        testImplementation 'com.github.codemonstur:embedded-redis:1.4.3'

        //parametererized test
        testImplementation 'org.junit.jupiter:junit-jupiter-params'
        testCompileOnly 'org.projectlombok:lombok'
        testAnnotationProcessor 'org.projectlombok:lombok'
    }

    def generated = 'src/main/generated'

    // querydsl QClass 파일 생성 위치를 지정
    tasks.withType(JavaCompile) {
        options.getGeneratedSourceOutputDirectory().set(file(generated))
    }

    // java source set 에 querydsl QClass 위치 추가
    sourceSets {
        main.java.srcDirs += [ generated ]
    }

// gradle clean 시에 QClass 디렉토리 삭제
    clean {
        delete file(generated)
    }

    tasks.named('test') {
        useJUnitPlatform()
    }

    tasks.getByName("bootJar"){
        enabled = false
    }

    tasks.getByName("jar"){
        enabled = true
    }
}

 

위와 같이 apply 를 적용함으로 써 특정 서브 모듈에서 필요한 플러그인은 해당 서브 모듈에서 따로 정의하여 적용할 수 있다. 현재는 모듈이 하나밖에 없기 때문에 기존의 플러그인을 그대로 submodule에 적용해놓은 상태이다. 최종적으로 build.gradle 파일에서 dependency 관련 설정 빼고는 버전에 관련된 모든 숫자가 제거된 것을 확인할 수 있다. 

 

 

Reference) https://www.youtube.com/results?search_query=%EC%A0%9C%EB%AF%B8%EB%8B%88%EC%9D%98+%EA%B0%9C%EB%B0%9C%EC%8B%A4%EB%AC%B4

개인 프로젝트에서 진행했던 알람 시스템 구현에 대해서 포스팅 하려한다. 

알람 시스템을 구현하기 위해서는 서버의 알람 이벤트를 클라이언트에게 전송할 수 있어야 하는데  이 때 고려했던 방법은 SSE 연결과 웹소켓을 이용한 연결이었다. 알람 전송을 위해서는 단방향 전송이 필요했기 때문에 SSE 연결을 통해 알람 기능을 구현하고자 했다. 


알람 구독 시나리오는 다음과 같다. 클라이언트 사이드에서는 javascript 가 제공하는 eventsource 객체를 통해 서버의 /subscribe 로 연결 요청을 보내고 연결을 맺는다. 서버에서는 in-memory 방식으로 해당 클라이언트의 연결정보를 관리하면서 연결을 유지한다. 아래는 클라이언트와 서버와 Sse 연결을 맺는 기본 구현 코드이다. 

@GetMapping(value = "/subscribe", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public ResponseEntity<SseEmitter> subscribe(
        @CurrentMember Member member,
        @RequestHeader(value = "Last-Event-ID", required = false) String lastEventId) {
    return ResponseEntity.ok(sseEmitterService.subscribe(member, lastEventId));
}
public SseEmitter subscribe(Member member, String lastEventId) {
    SseEmitter emitter = new SseEmitter(DEFAULT_TIMEOUT);
    String uniqueEmitterId = generateUniqueClientId(member.getEmail(),LocalDateTime.now());
    sseEmitterRepository.save(uniqueEmitterId, emitter);

    emitter.onCompletion(() -> sseEmitterRepository.deleteById(uniqueEmitterId));
    emitter.onTimeout(() -> sseEmitterRepository.deleteById(uniqueEmitterId));
    emitter.onError((e) -> sseEmitterRepository.deleteById(uniqueEmitterId));

    sendToClient(emitter,uniqueEmitterId,"init","init");

    if(lastEventId != null){
        LocalDateTime dateTime = extractDateTime(lastEventId);
        List<Notification> notifications = notificationService.findAllAfterDateTime(dateTime, member);
        notifications.forEach(
                notification -> sendToClient(emitter,
                        uniqueEmitterId,
                        "notification",
                        NotificationResponse.from(notification))
        );
    }

    return emitter;
}
public void sendToClient(SseEmitter emitter, String id, String name, Object data) {
    try {
        emitter.send(
                SseEmitter.event()
                        .id(id)
                        .name(name)
                        .data(data)
        );
    } catch (IOException e) {
        log.error(e.getMessage(), e);
        sseEmitterRepository.deleteById(id);
        throw new RuntimeException(FAILED_SSE_NOTIFICATION_SEND.getMessage());
    }
}
@Repository
public class SseEmitterRepository {
    private Map<String, SseEmitter> emitterMap = new ConcurrentHashMap<>();

    public List<SseEmitter> findAllStartWithByMemberEmail(String email) {
        return emitterMap.entrySet().stream()
                .filter(entry-> entry.getKey().startsWith(email))
                .map(Entry::getValue)
                .toList();
    }

    public SseEmitter save(String id, SseEmitter emitter) {
        return emitterMap.put(id, emitter);
    }

    public void deleteById(String uniqueEmitterId) {
        emitterMap.remove(uniqueEmitterId);
    }

    public void deleteAll(){
        emitterMap.clear();
    }
}

 

톰캣의 쓰레드풀에 있는 각 쓰레드당 하나의 연결 요청을 처리하기 때문에 공유 자원에 대한 경쟁상황이 생길 수 있으므로

ConcurrentHashMap을 사용하여 SseEmitter 객체를 관리한다. 


 

해당 프로젝트에서 알람이 발생하는 상황은 다음과 같다.

  •  A유저의 게시글에 B 유저가 댓글을 달았을 때
  •  A유저의 댓글에 B 유저가 대댓글을 달았을 떄
  • Todo 업데이트 마감 시간 공지
  • 새벽 1시 각 유저에게 성취도 피드백 메시지 공지 

단일서버에서 1번 상황이 발생했다고 가정했을 때는 모든 요청이 하나의 서버로만 전달 되기때문에 해당 서버는 반드시 알람 구독을 맺고있는 클라이언트의 정보를 갖고 있을 것이다. 그렇기 때문에 A의 게시글에 B 의 댓글이 등록될 때 댓글 등록 이벤트를 발생시키고 이벤트 리스너에서 해당 이벤트를 수신하여 A에게 알람을 전송하면 문제가 없다. 

 

하지만 본 프로젝트는 단일서버가 아닌 Scale-out 된 분산서버 환경을 고려하고 설계하였기 때문에 서버가 여러개인 상황을 가정하면, 댓글 등록 이벤트를 발생시킨 서버에서 A유저의 연결정보가 서버에 존재한다고 보장할 수 없다. 왜냐하면 DB에 따로 연결정보를 관리하지 않고 in-memory 형식으로 연결정보를 관리하고 있기 때문이다. 

그렇기 때문에 댓글 등록 이벤트가 발생했을 때 A의 연결정보를 갖고 있는 서버에서 댓글 등록 이벤트를 처리하고 알림을 전송할 수 있도록 하는 구조설계가 필요했다. 

 

위와 같은 이유로 Kafka 를 통한 Producer-Consumer 그리고 Redis Pub/Sub 두가지를 고려했다. Kafka 는 토픽을 구독하는 컨슈머 그룹내에 하나의 컨슈머가 토픽에 발행된 메시지를 처리하게 되는데 이 때 해당 컨슈머가 A의 연결정보를 가지고 있다라고 보장할 수 없는 상황이긴 마찬가지였다. 그렇기 때문에 채널을 구독하고 있는 모든 Subcriber가 메시지를 수신하고 해당 메시지를 처리할 수 있는 서버(Subscriber)에서 알람 전송을 처리할 수 있는 구조인 Redis Pub/Sub 구조를 채택하게 되었다. 

 

먼저 레디스 채널에 전송할 Event 객체를 구현했다.

public class NotificationEvent {
    String clientEmail;
    Long targetEntityId;
    Long relatedEntityId;
    String message;
    NotificationType notificationType;
}

 

각 필드에 대한 설명

1. SseEmitter 객체를 찾을때 clientEmail을 기반으로 연결을 찾도록 설계하였기 때문에 clientEmail 을 필드로 갖는다.

2. targetEntityId - 게시글에 댓글이 달렸다면 해당 게시글의 id 를 필드로 갖는다

3. relatedEntityId - 게시글에 댓글이 달렸다면 해당 댓글의 id 를 필드로 갖는다.

4. message - 이벤트 발생상황에 맞는 메시지 

@Getter
public enum NotificationMessage {
    REPLY_ADD("회원님의 게시글에 댓글이 달렸어요 :) ") ,
    NESTED_REPLY_ADD("회원님의 댓글에 댓글이 달렸어요 :)"),
    TODO_UPDATE("잊지 않으셨죠 ? 투두 리스트 제출 마감 시간은 오후 11시 입니다 :)"),
    ;
    private final String message;

    NotificationMessage(String message) {
        this.message = message;
    }
}

5. NotificationType - 각 상황에 맞는 알림 타입

@Getter
public enum NotificationType {
    REPLY_ADD("댓글 추가", "/board",true),
    NESTED_REPLY_ADD("대댓글 추가", "/board", true),
    TODO_UPDATE("TODO 업데이트", null, true),
    ACHIEVEMENT_FEEDBACK("성취도", null, true),
    ;

    private final String value;
    private final String baseTargetUrl;
    private final Boolean isNeededSave;

    NotificationType(String value, String baseTargetUrl,Boolean isNeededSave) {
        this.value = value;
        this.baseTargetUrl = baseTargetUrl;
        this.isNeededSave = isNeededSave;
    }
}

 

1. 위 dto 객체를 생성하여 스프링의 EventPublisher를 통해 각 알림 타입에 맞는 이벤트를 비동기 발행한다. 해당 이벤트는 댓글이 생성될 때 발행하게 되는데 해당 이벤트의 발행이 댓글 생성 및 저장에 영향을 주어서는 안되고 동기적으로 처리될 필요가 없다고 생각했기 때문에 비동기처리 하였다. 

2. publishNotificationEvent 메서드에서 해당 이벤트를 수신하고 레디스 토픽("NotificationTopic")에 publish 한다.

3. 레디스 토픽을 구독하고 있는 서버에서 handleNotificationEvent 함수를 통해 토픽에 발행된 메시지를 수신하고 알람을 보낸다. 

@Async
public void publishReplyAddEvent(Board board, Reply reply) {
    Member boardWriter = board.getMember();
    Member replyWriter = reply.getMember();

    if(reply.getParent() != null){
        eventPublisher.publishEvent(new NotificationEvent(
                reply.getParent().getMember().getEmail(),
                board.getId(),
                reply.getId(),
                NotificationMessage.NESTED_REPLY_ADD.getMessage(),
                NotificationType.NESTED_REPLY_ADD));
    }

    if (!boardWriter.getEmail().equals(replyWriter.getEmail())) {
        eventPublisher.publishEvent(new NotificationEvent(
                boardWriter.getEmail(),
                board.getId(),
                reply.getId(),
                NotificationMessage.REPLY_ADD.getMessage(),
                NotificationType.REPLY_ADD));
    }
@Async
@EventListener
public void publishNotificationEvent(NotificationEvent event){
    try {
        String channelEvent = objectMapper.writeValueAsString(event);
        redisOperations.convertAndSend(notificationTopic.getTopic(), channelEvent);
    } catch (JsonProcessingException e) {
        log.error(e.getMessage(), e);
        throw new InvalidRedisMessageException(INVALID_REDIS_MESSAGE);
    }
}
public void handleNotificationEvent(String message) {
    NotificationEvent event = convertToObject(message, NotificationEvent.class);

    Member member = memberService.findByEmail(event.getClientEmail());

    Notification notification = Notification.from(member, event);
    if (notification.getNotificationType().getIsNeededSave()) {
        notificationService.save(notification);
    }

    List<SseEmitter> emitters = sseEmitterService.findAllStartWithByMemberEmail(member.getEmail());
    if (!emitters.isEmpty()) {
        String response = convertToJson(NotificationResponse.from(notification));
        emitters.forEach(emitter -> sseEmitterService.sendToClient(
                emitter,
                sseEmitterService.generateUniqueClientId(member.getEmail(), LocalDateTime.now()),
                NOTIFICATION_EVENT_NAME,
                response)
        );
    }
}

 

만약 SSE 연결이 끊겨있는 상황에서는 알람을 수신하지 못하기 때문에  알람을 RDB 에 저장해놓고 유저가 다시 SSE 연결을 맺었을 때 받지 못했던 알람을 수신할 수 있게 하였다. 구조를 개선하기 전 알람 기능의 문제는 결국 stateful 하게 설계되었기 때문에 발생하는 문제였고 stateless 하게 변경하면서 문제를 개선할 수 있었다.

결론은 알람이 이벤트가 어디에서 발생이 되어도 분산되어 있는 서버 중 처리할 수 있는 서버에서 알람을 처리할 수 있게 되었다.

사용 Tools : SQL Procedure , Gatling

Tool 선정

개발 서버용 더미 데이터를 추가하기 위해 여러가지 툴을 검색해 보았다.

가장 간편하게 사용할 수 있는 방법을 위주로 보았는데 후보군에 Mockaroo, Procedure 가 있었다. Mockaroo 는 프로그래밍 코드 없이 쉬운 UI 로 간단하게 데이터를 생성할 수 있다는 장점이 있었는데 무료버전은 테스트 데이터를 한번에 1000rows 밖에 생성하지 못했고 비교적 더미데이터를 생성하는 쿼리 포맷자체도 간단했기에 Procedure를 작성하기로 했다.

데이터 생성 및 ERD

내가 생성하려는 데이터는 (게시글,게시글 좋아요, 게시글 스크랩) ,(댓글 및 대댓글, 댓글 좋아요), (변동일정), (고정일정,고정일정 세부사항), (일반일정,일반일정 세부사항),(유저, 유저의 auth 정보) 였다.

 

1. 유저 데이터 추가

Member 테이블 구조에 맞게 Procedure 를 작성했고 총 1000개의 더미 데이터를 삽입했다.

```sql
BEGIN
    DECLARE i INT DEFAULT 1;

    WHILE i <= 1000 DO
        INSERT INTO member (
            username, 
            nickname, 
            email, 
            password, 
            gender, 
            mode, 
            role, 
            image_file, 
            created_at, 
            created_by, 
            updated_at, 
            updated_by, 
            score, 
            phone_number, 
            auth_email, 
            auth_phone
        ) VALUES (
            CONCAT('홍길동', i), 
            CONCAT('닉네임', i), 
            CONCAT('normal', i, '@example.com'), 
            '$2a$12$vVyp1MKvgHaS68VKu/gyjeaFqHiXzKiu8Cq5A8jeoLZzHM900.0X2', 
            'MALE', -- 성별 예제 값 (M: 남성, F: 여성)
            'MILD', -- 모드 예제 값
            'NORMAL_USER', 
            NULL,
            NOW(), -- 생성 시간
            'ADMIN', -- 생성자
            NOW(), -- 업데이트 시간
            'ADMIN', -- 업데이트자
            FLOOR(RAND() * 100), -- 점수 (0~99 랜덤 값)
            CONCAT('011', LPAD(i, 8, '0')),
            false, 
            false 
        );
        SET i = i + 1;
END WHILE;
END

```

 

2. 인증 데이터 추가

 Auth 테이블 구조에 맞게 Procedure 를 작성했고 앞서 생성한 멤버 데이터와 1:1 매핑이 되는 1000개의 더미 데이터를 삽입했다.

```sql
BEGIN
    DECLARE i INT DEFAULT 1;

    WHILE i <= 1000 DO
        INSERT INTO AUTH (
            email, refresh_token, created_at, created_by, updated_at, updated_by
        ) VALUES (
            CONCAT('normal', i, '@example.com'), 
            UUID(),                           
            NOW(),                              
            'ADMIN',          
            NOW(),                             
            'ADMIN'
        );

        SET i = i + 1;
    END WHILE;
END
```

 

 

3. 게시글 ,게시글 좋아요, 게시글 스크랩 데이터 추가

 

 

게시글 약 1만개 , 게시글 좋아요 약 2.3만개 , 게시글 스크랩 약 2.3만개 생성

```sql
BEGIN
    DECLARE board_idx INT DEFAULT 1; -- 게시글 ID
    DECLARE member_count INT DEFAULT 1000; -- 멤버 수
    DECLARE max_boards INT DEFAULT 10000; -- 게시글 수
    DECLARE max_hearts INT DEFAULT 6; -- 게시글 좋아요 최대 수 (0~5개)
    DECLARE max_scraps INT DEFAULT 6; -- 게시글 스크랩 최대 수 (0~5개)

    -- 게시글 삽입
    WHILE board_idx <= max_boards DO
        -- 게시글 삽입
        INSERT INTO BOARD (
            member_id, title, contents, created_at, updated_at, total_like, total_scrap
        ) VALUES (
            FLOOR(RAND() * member_count) + 1, -- 랜덤 멤버
            CONCAT('Board Title ', board_idx),
            CONCAT('Content for board ', board_idx), 
            NOW(), 
            NOW(), 
            0, 
            0 
        );


        SET @last_board_id = LAST_INSERT_ID();

        -- 랜덤하게 게시글 좋아요 생성 (0~5개)
        SET @board_hearts = FLOOR(RAND() * max_hearts); -- 좋아요 수
        WHILE @board_hearts > 0 DO
            INSERT INTO BOARD_HEART (
                board_id, member_id
            ) VALUES (
                @last_board_id, -- 게시글 ID
                FLOOR(RAND() * member_count) + 1 -- 랜덤 멤버 ID
            );

            -- BOARD 테이블의 total_like 업데이트
UPDATE BOARD
SET total_like = total_like + 1
WHERE board_id = @last_board_id;

SET @board_hearts = @board_hearts - 1;
END WHILE;

        -- 랜덤하게 게시글 스크랩 생성 (0~5개)
        SET @board_scraps = FLOOR(RAND() * max_scraps); -- 스크랩 수
        WHILE @board_scraps > 0 DO
            INSERT INTO BOARD_SCRAP (
                board_id, member_id
            ) VALUES (
                @last_board_id, -- 게시글 ID
                FLOOR(RAND() * member_count) + 1 -- 랜덤 멤버 ID
            );

            -- BOARD 테이블의 total_scrap 업데이트
UPDATE BOARD
SET total_scrap = total_scrap + 1
WHERE board_id = @last_board_id;

SET @board_scraps = @board_scraps - 1;
END WHILE;

        SET board_idx = board_idx + 1;
END WHILE;
END
```

게시글
게시글 좋아요
게시글 스크랩

4. 댓글, 대댓글, 댓글 좋아요 데이터 추가

 

댓글(대댓글 포함) 약 24만개 , 댓글 좋아요 약 22만개 

 

```sql
BEGIN
    DECLARE board_idx INT DEFAULT 1; -- 게시글 ID
    DECLARE reply_idx INT DEFAULT 1; -- 댓글 카운터
    DECLARE member_count INT DEFAULT 1000; -- 멤버 수
    DECLARE max_replies INT DEFAULT 10; -- 게시글당 댓글 수

    WHILE board_idx <= 10000 DO
        SET reply_idx = 1;

        WHILE reply_idx <= max_replies DO
            -- 댓글 삽입
            INSERT INTO REPLY (
                member_id, board_id, parent_id, contents, total_like, created_at, updated_at
            ) VALUES (
                FLOOR(RAND() * member_count) + 1, 
                board_idx,                        
                NULL,                            
                CONCAT('Reply content for board ', board_idx, ' reply ', reply_idx),
                0,                               
                NOW(),                           
                NOW()                             
            );

            -- 방금 삽입된 댓글의 ID 가져오기
            SET @last_reply_id = LAST_INSERT_ID();

            -- 랜덤하게 대댓글 생성 (0~3개)
            SET @sub_replies = FLOOR(RAND() * 4); -- 대댓글 수
            WHILE @sub_replies > 0 DO
                INSERT INTO REPLY (
                    member_id, board_id, parent_id, contents, total_like, created_at, updated_at
                ) VALUES (
                    FLOOR(RAND() * member_count) + 1, 
                    board_idx,                        
                    @last_reply_id,                   -- 대댓글의 parent_id
                    CONCAT('Sub-reply for reply ', @last_reply_id),
                    0,                                
                    NOW(),                            
                    NOW()                             
                );
                SET @sub_replies = @sub_replies - 1;
END WHILE;

            -- 랜덤하게 댓글 좋아요 생성 (0~5개)
            SET @hearts = FLOOR(RAND() * 6); -- 좋아요 수
            WHILE @hearts > 0 DO
                INSERT INTO REPLY_HEART (
                    reply_id, member_id
                ) VALUES (
                    @last_reply_id, -- 좋아요가 눌린 댓글 ID
                    FLOOR(RAND() * member_count) + 1 -- 랜덤 멤버
                );

                -- REPLY 테이블의 total_like 업데이트
UPDATE REPLY
SET total_like = total_like + 1
WHERE reply_id = @last_reply_id;

SET @hearts = @hearts - 1;
END WHILE;

            SET reply_idx = reply_idx + 1;
END WHILE;

        SET board_idx = board_idx + 1;
END WHILE;
END
```

 

댓글
댓글 좋아요

5. 고정일정 추가

 

 

일정 약 1000개,고정일정 약 1000개 , 고정일정 세부사항 약 15000개 

```sql
BEGIN
    DECLARE member_idx INT DEFAULT 1; -- 멤버 ID
    DECLARE max_members INT DEFAULT 1000; -- 멤버 수
    DECLARE max_schedules INT DEFAULT 3; -- 각 멤버당 일정 수
    DECLARE schedule_idx INT DEFAULT 1; -- 일정 인덱스
    DECLARE schedule_id BIGINT DEFAULT NULL; -- 일정 ID
    DECLARE f_schedule_id BIGINT DEFAULT NULL; -- F_SCHEDULE ID
    DECLARE schedule_start_date DATETIME DEFAULT NULL; -- 일정 시작일
    DECLARE schedule_end_date DATETIME DEFAULT NULL; -- 일정 종료일
    DECLARE current_loop_date DATETIME DEFAULT NULL; -- 반복 중인 날짜
    DECLARE loop_date DATETIME DEFAULT NULL; -- 반복할 날짜

    WHILE member_idx <= max_members DO
        SET schedule_idx = 1;

        WHILE schedule_idx <= max_schedules DO
            -- 변수 초기화
            SET schedule_id = NULL;
            SET schedule_start_date = NULL;
            SET schedule_end_date = NULL;
            SET current_loop_date = NULL;
            SET loop_date = NULL;

            -- SCHEDULE 테이블에 일정 삽입
            INSERT INTO SCHEDULE (
                member_id, title, common_description, start_date, end_date, created_at, created_by, updated_at, updated_by, dtype
            ) VALUES (
                member_idx,
                CONCAT('Schedule ', member_idx, ' - ', schedule_idx),
                CONCAT('Description for schedule ', schedule_idx, ' of member ', member_idx),
                NOW() + INTERVAL (schedule_idx * 10) DAY,
                NOW() + INTERVAL (schedule_idx * 12) DAY,
                NOW(),
                CONCAT('normal', member_idx, '@example.com'),
                NOW(),
                CONCAT('normal', member_idx, '@example.com'),
                'fixed'
            );

            SET schedule_id = LAST_INSERT_ID();

            -- 데이터 검증
            IF schedule_id IS NULL THEN
                SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Failed to retrieve LAST_INSERT_ID.';
            END IF;

            SELECT start_date, end_date 
            INTO schedule_start_date, schedule_end_date
            FROM SCHEDULE
            WHERE SCHEDULE.schedule_id = schedule_id;

            IF schedule_start_date IS NULL OR schedule_end_date IS NULL THEN
                SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Failed to retrieve start_date or end_date.';
            END IF;

            -- F_SCHEDULE 테이블에 삽입
            INSERT INTO F_SCHEDULE (schedule_id) VALUES (schedule_id);
            SET f_schedule_id = LAST_INSERT_ID();

            -- 반복 일정 생성
            SET current_loop_date = schedule_start_date;

            WHILE current_loop_date <= schedule_end_date DO
                SET loop_date = CONCAT(DATE(current_loop_date), ' 13:00:00');

                INSERT INTO F_SCHEDULE_DETAIL (
                    schedule_id, detail_description, start_date, end_date, created_by, created_at, updated_by, updated_at
                ) VALUES (
                    f_schedule_id,
                    CONCAT('Detail description for schedule ', schedule_idx, ' of member ', member_idx),
                    loop_date,
                    DATE_ADD(loop_date, INTERVAL 1 HOUR),
                    CONCAT('normal', member_idx, '@example.com'),
                    NOW(),
                    CONCAT('normal', member_idx, '@example.com'),
                    NOW()
                );

                SET current_loop_date = DATE_ADD(current_loop_date, INTERVAL 1 DAY);
            END WHILE;

            SET schedule_idx = schedule_idx + 1;
        END WHILE;

        SET member_idx = member_idx + 1;
    END WHILE;
END
```

일정
고정일정 (엔티티 상속관계)
고정일정 세부사항

 

6.  변동일정 추가

 

변동일정 약 9만개 

 

```sql
BEGIN
    DECLARE member_idx INT DEFAULT 1; -- 멤버 ID
    DECLARE max_members INT DEFAULT 1000; -- 멤버 수
    DECLARE max_schedules INT DEFAULT 100; -- 각 멤버당 일정 수
    DECLARE schedule_idx INT DEFAULT 1; -- 일정 인덱스
    DECLARE random_start_date DATETIME; -- 랜덤 시작일
    DECLARE random_end_date DATETIME; -- 랜덤 종료일

    -- 각 멤버에 대해 일정 생성
    WHILE member_idx <= max_members DO
        SET schedule_idx = 1;

        WHILE schedule_idx <= max_schedules DO
            -- 시작일 생성 (무작위로)
            SET random_start_date = 
                '2023-01-01 00:00:00' + INTERVAL FLOOR(RAND() * 730) DAY + INTERVAL FLOOR(RAND() * 24) HOUR;

            SET random_end_date = random_start_date + INTERVAL 2 HOUR;

            INSERT INTO SCHEDULE (
                member_id, title, common_description, start_date, end_date, created_at, created_by, updated_at, updated_by, dtype
            ) VALUES (
                member_idx, -- 멤버 ID
                CONCAT('Schedule ', member_idx, ' - ', schedule_idx), 
                CONCAT('This is a Variable schedule for member ', member_idx), 
                random_start_date, -- 랜덤 시작일
                random_end_date, -- 종료일 (시작일 + 2시간)
                NOW(), -- 생성 시간
                CONCAT('normal', member_idx, '@example.com'), 
                NOW(), -- 수정 시간
                CONCAT('normal', member_idx, '@example.com'),
                'fixed' -- dtype
            );

            -- V_SCHEDULE 테이블에 삽입
            INSERT INTO V_SCHEDULE (schedule_id)
            VALUES (LAST_INSERT_ID());

            SET schedule_idx = schedule_idx + 1;
        END WHILE;

        SET member_idx = member_idx + 1;
    END WHILE;
END $$

```

고정일정

 

일정

 

 

7.일반일정 추가

 

일반일정은 Gatling 으로 데이터를 삽입해주었다. 일반일정은 다른 일정들과 다르게  유저가 아래 폼 데이터를 요청하면 , 폼 데이터를 분석해 자동으로 일정을 생성해주기 때문에 일반일정 세부사항을 Procedure와 같이 특정패턴으로 데이터를 생성해서 주입할 수 없었기 때문이다.

일정 생성 폼

 

ChatGpt 의 도움을 받아 시뮬레이션 코드를 작성했다. 인증된 회원만 일정생성 리소스에 접근이 가능했기에 먼저 로그인 후에 각 유저별로 Authorization 헤더에 access token 을 먼저 주입시켜준 뒤 테스트 데이터를 삽입했다. 원래 Gatling은 앱 성능 테스트 도구인데 각 유저마다 인증 토큰이 주입된 요청을 날리기에 괜찮아 보여서 해당 툴을 사용했다. 

 

일반일정 약 1.8만개 

```scala
class LoginSimulation extends Simulation {

  // HTTP Protocol 설정
  val httpProtocol = http
    .baseUrl("http://localhost:8080") // API의 기본 URL
    .contentTypeHeader("application/x-www-form-urlencoded") // Content-Type 설정

  // 1000명의 순차적인 이메일 생성
  val userFeeder = (1 to 1000).map(i => Map(
    "email" -> s"normal$i@example.com", 
    "password" -> "password1"          
  )).iterator 
  
  val debugFeeder = exec { session =>
    val email = session("email").asOption[String]
    println(s"Feeder supplied email: ${email.getOrElse("No email in session")}")
    session
  }

  // 1. 로그인 요청
  val login = exec(debugFeeder) 
    .exec { session =>
      val email = session("email").as[String]
      val password = session("password").as[String]
      println(s"Attempting login with email: $email and password: $password")
      session
    }
    .exec(
      http("Login Request")
        .post("/login") // 로그인 API 엔드포인트
        .formParam("email", session => session("email").as[String]) 
        .formParam("password", session => session("password").as[String]) 
        .check(
          status.is(200),  
          header("Authorization").saveAs("authToken") // Authorization 헤더 추출
        )
    ).exitHereIfFailed 

  // 2. 보호된 리소스 요청
  val authenticatedRequest = exec { session =>
    val authToken = session("authToken").asOption[String]
    println(s"Using Authorization token: ${authToken.getOrElse("No token in session")}")
    val randomTitle = scala.util.Random.alphanumeric.take(10).mkString
    val randomDescription = scala.util.Random.alphanumeric.take(20).mkString
    val randomTotalAmount = scala.util.Random.between(100, 301) // 100~300 사이의 랜덤 값
    session
      .set("randomTitle", randomTitle)
      .set("randomDescription", randomDescription)
      .set("randomTotalAmount", randomTotalAmount)
  }.exec(
    http("Protected Resource Request")
      .post("/schedule/normal") 
      .header("Authorization", session => session("authToken").as[String])
      .header("Content-type", "application/json")
      .body(StringBody(session => {
        val title = session("randomTitle").as[String]
        val description = session("randomDescription").as[String]
        val totalAmount = session("randomTotalAmount").as[Int]

        s"""
        {
          "title": "$title",
          "commonDescription": "$description",
          "startDate": "2024-10-30",
          "endDate": "2024-11-30",
          "bufferTime": "01:00:00",
          "performInDay": "04:00:00",
          "isIncludeSaturday": true,
          "isIncludeSunday": true,
          "totalAmount": $totalAmount,
          "performInWeek": 4
        }
        """
      }))
      .asJson 
      .check(status.is(201)) 
  )

  val scn = scenario("Login and Access Protected Resource")
    .feed(userFeeder)    
    .exec(login)         
    .pause(1)            
    .exec(authenticatedRequest) 

  setUp(
    scn.inject(
      atOnceUsers(1000)
    ).protocols(httpProtocol)
  )
}
```

일정
일반일정
일반일정 세부사항

 

 

총 생성 데이터 

약 70만개 

 현재 진행하고 있는 프로젝트에서 최대한 스프링에 대해 공부했던 내용들을 최대한 담아보려고 하고 배운다는 생각으로 프로젝트를 진행 중에 있다.  우리 프로젝트는 계획형(J)이 아닌 즉흥형(P)이지만 계획을 세워 체계적으로 살고자 하는 사람들을 타겟으로 일정을 대신 세워주는 프로그램을 만들고 있다. 

즉 우리 핵심 비즈니스 로직이 일정과 관련되어 구현되어 있고, 좀 더 체계적이고 객체지향 관점에 맞게 설계하려고 노력했다. 그렇기 때문에 어플리케이션 레벨에서 다형성을 적용할 수 있는, 상속관계 매핑을 고려해서 적용해서 설계했다.

 

우리 ERD의 일부는 다음과 같다. 

 

먼저 데이터베이스 레벨관점에서는 상속관계 매핑 전략 중 더 정규화된 형태의 데이터관리를 위해 Join  전략을 선택하여 구현했고, 각각의 공통필드와 개별적으로 필요한 필드를 분리하여 테이블 설계를 했다. 

 

 어플리케이션 레벨의 관점에서는 흔히 사용하는 BaseEntity 에 공통필드를 정의하고 상속받는것 처럼 우리 프로젝트에 존재하는 3가지 유형의 일정에 대해서도 공통적으로 처리할 수 있는 필드에 대해서는 추상클래스를 사용하면 구조적으로 이점을 가져갈 수 있지 않을까 라고 생각을 했다. 

 

하지만 막상 구현을 해보니 내가 생각한 관점보다는 조금 더 깊게 고민해봐야할 요소들이 꽤 존재했다.

 

일정 추상클래스
일반일정 Entity

 

변동일정 Entity
고정일정 Entity

 

추상메서드를 설계하고 , 인터페이스를 설계하는 이유는 다형성을 적용했을 때의 이점 때문이라고 생각을 한다. 다형성을 적용하기 위해서는 정의된 함수의 시그니처를 따르는 함수를 자식클래스에서 구현해야하는데, 우리 프로젝트에서는 일단 첫번째로 각각의 엔티티를 살펴보면 자식클래스들이 가지고 있는 필드들이 다 다른 상황에서 하나의 시그니처를 가지고 각 일정의 유형에 맞는 로직을 만들기가 구조적으로 어려웠다. 

 

일정 수정, 일정 삭제와 같은 API 구현할 때 공통 수정 엔드포인트 1개, 공통 삭제 엔드포인트 1개를 구현한다고 했을 때, 

예를 들면 다음과 같은 방향으로 구현하고 싶었다 

 

- PUT Mapping /Schedule/{id}

 

    - Schedule 엔티티에 update 메서드를 선언하고 각각의 일정에서 update 메서드를 구현 

    - schedule service -> id와 매핑되는 일정 조회 

    - schedule service ->  각 유형의 일정 수정에 쓰이는 공통 dto을 schedule.update 메서드의 파라미터로 넘겨서 수정 

    - dirty checking  

    - schedule controller -> scheduleService.update() 호출 

    - schedule controller -> scheduleService.findById() 호출

    - response dto 반환 

 

하지만 각 유형의 일정마다 맺고 있는 연관관계가 다르고 필드도 달라서 하나의 공통 Dto를 받는 시그니처의 메서드를 구현한다는 것이 쉽지않았다.  예를 들어서 , 일정을 수정한다고 했을 때 일반일정은 totalAmount, bufferTime, 슈퍼클래스의 필드들, 더 나아가 일반일정과 연관관계에 있는 다른 테이블의 정보까지 함께 수정되어야 하고  변동일정은 일정의 시작시간과 종료시간, 슈퍼클래스의 필드를 수정해야 했다. 즉 하나의 추상메서드를 뽑아서 구현하기에는 어려움이 존재했고 각각의 일정에 맞는 일정 수정 메서드를 구현하는게 맞다고 생각했다. 

 

이 과정에서 들었던 생각을 나열해보면 

 

 각각의 일정에 맞는 로직을 구현하기 위해 각각의 메서드를 만들어야 한다면 추상클래스를 제대로 사용하고 있는것이 맞을까..?

 

테이블 상속관계를 유지하는 것이 맞을까 ..?  

 

일반일정, 변동일정, 고정일정이 일정이라는 공통된 속성과 기능을 가진다고 생각했지만 상속관계를 가지지 않는 개별적인 엔티티의 관점으로 보는게 맞는게 아닐까 ..? 

 

 결론적으로 현재 구조에서 추상클래스를 제대로 활용하지 못하고 있기 때문에 설계된 테이블 상속관계는 비효율적이라고 생각했다.


또한 어플리케이션 레벨에서의 구조적인 부분도 문제지만, 일정하나를 조회하더라도 일정테이블 3개를 조인 해야하는 DB 관점에서도 봤을 때 일정이 몇백만개 단위로 존재한다고 가정하면 코스트가 작지 않을 것이라고 예상한다. 

 

아마도 추후에 테이블 구조를 변경하게 될 것 같다.

 

상속관계 매핑은 신중히 고민하자 !!!!!!!!!

 

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

Spring JPA + Querydsl 환경설정 (SpringBoot 3.2.x 이상)  (0) 2024.05.06

프로젝트를 빌드했을 때 build/generated/sources/annotationProcessor/java/main/.... 에 Q파일들이 생성되는데요, 

소스코드를 찾는 과정에서 Q파일들의 코드를 인식하지 못하는 문제가 발생해서 source 세팅을 build.gradle 파일에 추가 해주었습니다.

 

아마 generated되는 Q파일의 경로를 /src/main/java 하위 경로에 생성되게 설정을 해두면 sourceSets 설정없이도 아마 인식할 것 같습니다. 

 

1. build.gradle 작성 

 - dependencies에 querydsl관련 설정 추가

 - sourceSets 에  "$projectDir/build/generated" 경로 추가 

 

2. gradle clean 후 다시 빌드 

 


build.gradle 공유 

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-devtools'
    implementation 'org.springframework.boot:spring-boot-starter-validation'
    compileOnly 'org.projectlombok:lombok'
    runtimeOnly 'com.h2database:h2'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'

    //Querydsl 추가
    implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta'
    annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jakarta"
    annotationProcessor("jakarta.persistence:jakarta.persistence-api") // java.lang.NoClassDefFoundError(javax.annotation.Entity) 발생 대응
    annotationProcessor("jakarta.annotation:jakarta.annotation-api") // java.lang.NoClassDefFoundError (javax.annotation.Generated) 발생 대응

}

sourceSets {
    main {
        java {
            srcDirs = ["$projectDir/src/main/java", "$projectDir/build/generated"]
        }
    }
}

tasks.named('test') {
    useJUnitPlatform()
}

clean {
    delete file('build/generated')
}

 

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

JPA 상속관계 매핑 - 신중히 생각하고 사용하자  (0) 2024.09.05

자바의 Immutable Object(불변 객체) 에 대해서 다뤄보려고 한다.

불변 객체가 무엇이고 왜 필요한 것일까? 그리고 어떻게 사용되고 있는 것일까? 

1. 불변 객체란 무엇일까?

"객체 지향 프로그래밍에 있어서 불변객체는 생성 후 그 상태를 바꿀 수 없는 객체를 말한다. 반대 개념으로는 가변 객체로 생성 후에도 상태를 변경할 수 있다"라고 정의되어 있다.  명료하지만 예시를 바로 떠올릴 수 없어서 다소 추상적이기도 하다. 

 

먼저 예시를 들면 자바에서 제공하는 기본 Wrapper Class ( String, Integer , Boolean ... ) 가 불변 객체에 해당한다. 

 

2.불변객체에 대한 메커니즘 이해 

 

우리가 생성하는 객체와 자바의 String,Integer 와 같은 기본객체들은 어떤 차이가 있을까?  

개발자들은 글을 보는 것보다 코드를 통한 이해가 더 빠르기 때문에 바로 예시를 들어 이야기 해보자.

 

우리가 정의한 A라는 클래스의 인스턴스를 생성하고, 해당 인스턴스를 myObject1,myObject2라는 변수가 참조할 때, 같은 곳을 참조하게 되고, A 오브젝트의 name 필드값이 변경되면 같은곳을 참조하고 있기 때문에 둘다 Bye라는 값을 반환한다. 

 

 

 

우리가 정의한 클래스 말고 자바에 정의된 기본객체들은 아래와 같은 간단한 코드가 있을 때 어떻게 동작할까 ?

우리가 정의한 객체와 같은 메커니즘으로 동작한다면 Hello,hi 가 아닌 hi,hi 가 나와야 한다. 

하지만 불변객체로 설계된 기본객체들은 참조값의 값을 변경하는 것이 아니라 새로운 공간에 새로운 값을 할당한다. 

즉, example1과 exampl2 가 같은 주소값(1번지라고 예시를 들겠음) 을 바라보고 있고 example2를 통해 새로운 문자열 "hi" 로 값을 변경한다해도 example1은 기존과 동일하게 1번지를 바라보게 되고 example2는 hi가 위치하고 있는 새로운 번지수를 바라보게 된다. 

 

3. 왜 불변객체를 사용하는가?

- 스레드 안전성: 여러 스레드가 동시에 같은 불변 객체를 참조하더라도, 어느 한 스레드가 객체의 상태를 변경할 수 없기 때문에 동기화 문제가 발생하지 않는다. 이는 복잡한 동기화 로직 없이도 데이터 일관성을 유지할 수 있게 해준다.

 

이해를 돕기 위한 시나리오를 추가하자면 

  • A가 "Hello"라는 데이터를 가진 String 객체를 읽고 있을 때, 이 데이터는 메모리에 저장된 후 변경될 수 없다. 즉, 객체 내부의 데이터는 생성 시점의 상태로 고정된다.
  • B의 변경 시도: B가 같은 "Hello" 값을 변경하고 싶다면, String 객체는 변경할 수 없기 때문에 새로운 String 객체를 생성해야 한다. 예를 들어, B가 "Hello"에 "Bye"를 추가하고 싶다면 new String("HelloBye")와 같은 새로운 객체를 만들 수 있다. 하지만 이는 원래 A가 참조하고 있는 "Hello" 객체와는 전혀 다른, 새로운 메모리 주소에 저장된 객체이다.
  • 결과: 따라서 A가 읽고 있는 "Hello" String 객체는 그대로 유지되며, B의 작업은 원래 객체에 영향을 주지 않는다. A는 여전히 원본 "Hello" 데이터를 안전하게 읽을 수 있다.

- 데이터 일관성: 불변 객체를 사용하면 데이터의 신뢰성이 보장된다. 데이터가 예측 가능하고, 오류 가능성이 줄어들어 버그 발생 확률이 낮아진다.

 

- 메모리 효율성: 같은 값의 불변 객체는 메모리에서 한 번만 생성되어 여러 참조에서 공유될 수 있다. 이는 메모리 사용을 최적화하고 성능을 개선하는 데 도움이 된다.

 

4.불변객체를 만드는 방법 

 

1. setter를 사용하지 않는다. (객체의 상태를 변경하는 메소드 미제공)
2. private으로 선언한다.

3. final을 선언한다.

 

 

ImmutableCar객체를 보면 필드값을 수정할 수 없게 설계 되었다. 따라서 한번 지정한 값에 대해 불변이다.

 

하지만 불변객체 내에 가변객체를 참조받고 있다면 이야기가 달라진다. 

 

 

 

ImmutableCar 객체를 불변객체로 설계하기 위해 다음과 같이 코드를 작성했다. 하지만 Sonata객체 자체가 가변객체이기 때문에 Sonata 객체의 version에 대한 수정은 열려있다. 그렇기 때문에 불변객체가 되려면 참조객체 역시 불변객체여야 한다는 것을 알 수 있다.

 

5. 자바의 불변 List

자바는 List를 불변으로 만드는 방법을 제공한다. Collections의 unmodifiableList와 List.of 를 통해 불변 리스트를 얻을 수 있다. 

아래 예시와 같이 해당 리스트에 값을 추가하려고 하면 아래와 같은 친철한 Intellij의 설명과 , 오류를 만나게 된다.

 

 

하지만 위 Collections.unmodifiableList() 로 얻는 객체또한 완전한 불변이라고는 할 수 없는데, 만약 unmodifiableList의 element가 가변객체라면 위 Sonata의 예시처럼 가변객체 자체가 변경될 수 있다는점에 유의해야 한다. 

 

 

'Java > Base' 카테고리의 다른 글

[Java] Wrapper 클래스와 Boxing , Unboxing  (0) 2023.09.14

https://www.codetree.ai/training-field/frequent-problems/problems/tree-kill-all/description?page=1&pageSize=20

 

코드트리 | 코딩테스트 준비를 위한 알고리즘 정석

국가대표가 만든 코딩 공부의 가이드북 코딩 왕초보부터 꿈의 직장 코테 합격까지, 국가대표가 엄선한 커리큘럼으로 준비해보세요.

www.codetree.ai

 

 

우선은 문제를 차근차근 읽어보며 문제를 이해하려고 했다. 문제를 읽다보니 구현해야할 기능이 한 문제 내에 꽤 많아보였고,  평소에 이런 문제처럼 구현해야할 기능들이 많은 경우 조건을 놓치고 디버깅하는데에 시간을 많이 할애해야하는 경우가 많았기에 조금 더 꼼꼼히 읽고 기능을 정리 했다. 

 

문제의 입력으로 4개의 정수가 들어온다. 처음부터 각각이 의미하는 변수를 다 기억하는 것이 구현해야할 기능을 생각하고 코드를 구현하다보면 헷갈려서 대충 뭐가있다 정도로만 파악해두었다. 

 

위 그림과 같이 n x n 크기의 보드판에 나무( 0보다 큰 정수) , 벽 (-1 ) , 빈칸 ( 0 ) 에 대한 정보가 입력으로 주어진다. 이후 각각의 나무들은 문제에서 주어진 조건에 따라 성장과 번식을 하게 되고 이후 보드판을 살펴 나무가 있는 곳에 제초제를 뿌리리는데 이 때 제초제를 뿌렸을 때 가장 많은 나무를 박멸할 수 있는 칸을 선택하여 제초제를 뿌려야한다. 이 과정들 (성장, 번식, 제초) 이 1년 주기로 반복된다. 문제는 이 메커니즘으로 작동한다고 보면 되고 추가적으로 고려해야할 상황들이 있다.  

1. 나무의 성장

나무가 있는 칸을 기준으로 상,하,좌,우 방향을 살폈을 때 나무가 존재하는 칸의 수 만큼 해당칸의 나무의 개수가 늘어난다. 즉 위 그림의 46 그루의 나무가 있는 칸을 기준으로 살폈을 때 , 좌,우,하방향에 나무가 존재하고 있으므로 해당칸은 46 + 3 인 49이 된다. 이 때 성장은 성장은 모든 나무에게 동시에 일어난다.

아래의 기능들이 필요하다.

    - 방향을 체크할 때 보드판의 index범위를 벗어 날 수 있으므로 범위를 체크하는 기능 

    - 방향을 살피고 나무를 성장시키는 기능

2. 나무의 번식 

나무가 있는 칸을 기준으로 상,하,좌,우 살폈을 때 빈칸 (벽, 다른 나무, 제초제 모두 없는경우) 이 있는경우 , 각 빈칸에 해당 칸에 있는 나무의수 / 빈칸의 수 만큼의 나무가 번식한다. 이 때 나눌 때 생기는 나머지는 버리고 번식의 과정은 모든 나무에서 동시에 일어난다.

 

다음과 같은 기능들이 필요하다.

 

1. 방향을 체크할 때 보드판의 index범위를 벗어 날 수 있으므로 범위를 체크하는 기능 

2. 빈칸의 개수를 체크하고, 번식을 위한 빈칸의 위치를 기억하는 기능 

3. 번식될 나무의 개수를 계산하는 기능

 

나무의 성장과 번식 과정에서 확인해야 하는 방향이 상,하,좌,우 로 같고 둘다 2중 반복문을 돌아야 하기 때문에 하나의 기능으로 묶어서 구현했다. 

먼저 상,하,좌,우를 나타내는 DIRECTION1 , 대각선 방향을 나타내는 DIRECTION2 ( 이따 제초과정에서 사용됨) 을 정의하고 문제에서 주어진 변수들을 정의해주었다. 

각 변수를 입력받아 문제해결에 필요한 변수들을 초기화 시켜주었다. 

 

성장과 번식

 

cloneMatrix를 선언해준 이유는 나무가 번식할 때 기존 보드판을 업데이트 하게되면 번식한 칸 마저 기존에 나무가 있던 칸으로 인식되는 일이 발생하기 때문에 정보를 저장해줄 임시 보드판이 필요했다. 

deepCopy 는 2차원 배열 깊은 복사를 위해 따로 만들어주었다. 

 

 

이 함수에서 빈칸에 대한 정보를 담고, 나무의 성장이 일어난다. 급하게 코드를 작성한다고 메서드 명이 적절하지 않은 것 같긴하다..

checkMatrixRange 함수는 중복되는 기능이라 따로 분리해주었다. 간단하게 보드판의 indexRange 를 체크해주는 함수이다. 

 

 

 

3. 나무 제초 

각 칸 중 제초제를 뿌렸을 때 나무가 가장 많이 박멸되는 칸에 제초제를 뿌린다. 나무가 없는 칸에 제초제를 뿌리면 박멸되는 나무가 전혀 없는 상태로 끝이 나지만, 나무가 있는 칸에 제초제를 뿌리게 되면 4개의 대각선 방향으로 k칸만큼 전파되게 된다. 단 전파되는 도중 벽이 있거나 나무가 아얘 없는 칸이 있는 경우, 그 칸 까지는 제초제가 뿌려지며 그 이후의 칸으로는 제초제가 전파되지 않는다. 제초제가 뿌려진 칸에는 c년만큼 제초제가 남아있다가 c+1년째가 될 때 사라지게 된다. 제초제가 뿌려진 곳에 다시 제초제가 뿌려지는 경우에는 새로 뿌려진 해로부터 다시 c년동안 제초제가 유지된다.

 

만약 박멸시키는 나무의 수가 동일한 칸이 있는 경우에는 행이 작은 순서대로, 만약 행이 같은 경우에는 열이 작은 칸에 제초제를 뿌리게 된다.

 

편의를 위해 제초제년 수에 따라 -2 부터 작아지는 방향으로 생각하고 구현했다.

다음과 같은 기능이 필요하다.

 

1. 가장 많이 제초되는 칸을 구하는 기능 

2. 전파 될 곳을 확인하고 제초제를 전파하는 기능 

3. 전파된 땅의 값을 제초제 년수에 따라 업데이트하는 기능 

4. 제초된 나무들의 합을 구하는 기능 

5. 제초되는 최대 나무가 같은칸이 존재하는 경우 주어진 조건에 따라 인덱스를 계산하는 기능

 

 

 

 

 

 

+ Recent posts