기록/후기, 회고

쎄트렉아이 인턴 후기

gmelon 2022. 12. 7. 19:41

시작

올해 6월부터 8월까지 2달이 조금 넘는 기간동안 위성시스템을 개발하는 쎄트렉아이에서 인턴으로 근무했었다. 짧은 시간이었지만 많은 것들을 배웠고 아쉬운 점도 많았다. 이때 느꼈던 점들을 간략하게 정리해보려고 한다.

점심먹고 카페갔다가 다시 들어갈 때 잠을 깨워주던 귀여운 사냥이(팀원분들이 그렇게 부르시던데 아마 회사+고양이..로 추측🤔)로 시작 😋

배운 점

페어 프로그래밍과 클린코드

인턴 과정을 통해 가장 크게 배운점을 말해보라고 하면 좋은 코드에 대한 필요성을 느끼고 안목을 기르게 된 것을 얘기할 것 같다. 인턴 전까지 작성해본 코드라고는 학부 전공 과제 코드밖에 없었기 때문에 그저 동작하는 코드를 짜기에 바빴고 코드의 품질이나 유지보수성에 대해서는 고민해본적이 없었다.

인턴 과정은 대체적으로 사수님께서 과제를 내주시고 내가 해당 과제 코드를 다 작성하면 자리로 직접 오셔서 페어 프로그래밍 형태로 코드 리뷰를 해주시는 방식으로 진행되었다. 이 과정에서 좋은 코드란 무엇인지, 왜 코드를 작성할 때 유지보수성을 고려해야 하는지 등에 대해 많은 것을 배울 수 있었다.

그 중 기억에 남고 큰 도움이 되었던 내용들 3가지를 간략히 정리했다.

1. 요구사항 변경에 강한 구조로 리팩토링 (추상화 + DI)

인턴으로 들어가 가장 먼저 한 일은 위성 사진을 일정 주기로 스크랩해서 저장하는 모듈을 개발하는 것이었다. 그리고 해당 모듈을 통해 다운로드 한 이미지를 폐쇄망 host로 가져오는 모듈을 이어서 개발하게 되었다. 전체 구조를 정리해보면 대략 아래와 같은 요구사항이었다.

가장 먼저 얘기하고 싶은 내용이 뒤쪽 '스크랩 이미지 전송' 부분과 관련된 모듈이다. DMZ망의 host가 이미지를 스크래핑하면 폐쇄망의 host에 redis로 다운로드 받은 이미지의 정보와 저장된 경로가 담긴 메시지를 전송하도록 하고 폐쇄망의 host는 메시지가 수신될 때 메시지에 담긴 정보를 사용해 DMZ망의 host에서 이미지 파일을 가져오는 작업을 해야 했다.

이러한 모듈(폐쇄먕쪽 모듈)을 개발하기 위해 다음과 같이 구조를 설계하고 개발을 완료했다.

그런데 개발을 완료하고 며칠 뒤 협력사의 요청으로 요구사항이 변경되었다. 기존에는 이미지 전송의 트리거가 Redis 메시지 수신이었는데 이를 스케쥴러를 통해 메시지의 수신 없이도 알아서 동작할 수 있도록 기능을 추가해야했다.

현재의 설계에서 스케쥴러를 사용하는 방식을 지원하도록 기능을 추가하려고 보니, 현재의 구조가 변경에 취약한 구조임을 그제서야 알 수 있었다. Application이 구체 클래스인 RedisTransferController에 의존하고, 해당 Controller에서 필요한 정보를 Application에서 만들어 전달해주고 있었기 때문에 Application의 코드가 사용하려는 controller의 종류에 따라 변경되어야 했고, 이는 SOLID 중 OCP와 DIP에 위반된다는 생각이 들었다.

// 기존 Application 클래스
public class Application {
    public static void main(String[] args) {
        TransferConfig config = YamlParser.parse(args[0], TransferConfig.class);

        RedisTransferController controller = new RedisTransferController(config);
        controller.run();
    }
}

Application에서 사용하려는 controller의 타입 값을 받아 switch 등으로 직접 분기 처리를 해줘도 되지만, 그렇게 하면 새로운 controller 구현체가 추가될 때마다 새로운 분기문을 작성해주어야 하고 이때 새로운 구현체가 누락될 위험이 있기 때문에 좋지 않아 보였다. 이를 해결하기 위해 Application은 클라이언트 코드라고 보고 이 코드에 의존 객체를 주입해주는 팩토리 클래스를 두어 이를 스프링과 같은 일종의 DI 컨테이너로 사용하도록 코드를 개선해야겠다는 생각이 들었다.

먼저 TransferController 인터페이스를 생성해 Application은 이 인터페이스에 의존하도록 했다.

public static void main(String[] args) {
    TransferConfig config = YamlParser.parse(args[0], TransferConfig.class);

    // 인터페이스에 의존하도록 변경
    TransferController controller = new RedisTransferController(config);
    controller.run();
}

그리고 controller의 type을 의미하는 TransferControllerType enum 클래스를 만들고 해당 클래스에서 클라이언트가 사용하고자 하는 controller의 type 정보를 받아 TransferController의 구체 클래스 생성을 대신하도록 했다.

// TransferControllerType 에 전달될 설정 파일
@Data
public class TransferConfig {

    private String localRootDir;
    // 사용할 controller type을 지정하는 필드
    private String controllerType;
    private FileServer fileServer;

    /* 생략 */

}

구체 클래스를 생성할 때 메서드에서 분기문을 통해 생성 로직을 수행하면 새로운 type이 만들어졌을 때 자칫 생성 코드가 누락되어 잘못 동작하는 일이 발생할 수 있기에 TransferControllerType가 함수형 인터페이스 Function을 필드로 가지게 해서 새로운 controller type이 추가될 때 해당 타입에 맞는 구현체를 생성하는 코드가 누락되지 못하게 컴파일 타임에 강제할 수 있도록 했다.

// controller의 type을 의미하는 TransferControllerType enum
public enum TransferControllerType {
    REDIS(RedisTransferController::new);

    private final Function<TransferConfig, TransferController> transferControllerFactory;

    TransferControllerType(Function<TransferConfig, TransferController> transferControllerFactory) {
        this.transferControllerFactory = transferControllerFactory;
    }

    public static TransferController getInstance(TransferConfig config) {
        return Arrays.stream(values())
                .filter(value -> value.name().equalsIgnoreCase(config.getControllerType()))
                .findAny()
                .map(value -> value.transferControllerFactory)
                .orElseThrow(() -> new IllegalArgumentException("undefined controller type"))
                .apply(config);
    }
}
// Application
public class Application {
    public static void main(String[] args) {
        TransferConfig config = YamlParser.parse(args[0], TransferConfig.class);

        // TransferControllerType에게 구체 클래스 생성을 위임
        TransferController controller = TransferControllerType.getInstance(config);
        controller.run();
    }
}

이제 아래와 같이 새로운 TransferController의 새로운 구현체 ScheduleTransferController가 추가되어도 Application 코드는 변경 없이 TransferControllerType enum 클래스의 코드만 변경해주고 설정 파일에 사용하고자 하는 방식의 type만 넣어주면 된다. 기존의 클라이언트 코드를 전혀 건드리지 않고도 새로운 기능이 잘 동작하는 것을 확인할 수 있다.

// TransferController를 구현하는 또 따른 구현체 ScheduleTransferController
public class ScheduleTransferController implements TransferController {

    private final TransferConfig config;
    private final FileServer fileServer;

    // config files only for schedule transfer
    private ScheduleConfig scheduleConfig;
    private RemoteFilePathConfig remoteFilePathConfig;

    public ScheduleTransferController(TransferConfig config) {
        this.config = config;
        this.fileServer = config.getFileServer();
    }

    @Override
    public void run() {
        try {
            CustomScheduler customScheduler = new CronScheduler();

            // add schedule
            parseScheduleConfigs(config);
            registerSchedule(customScheduler);

            customScheduler.run();
        } catch (SchedulerException e) {
            log.error("An error occurred while registering the scheduler.", e);
            throw new RuntimeException(e);
        }
    }

    /* 생략 */

}
public enum TransferControllerType {
    REDIS(RedisTransferController::new),
    SCHEDULE(ScheduleTransferController::new);

    /* 이하 동일 */

}

RedisTransferController 실행

ScheduleTransferController 실행

최종적으로 아래와 같이 개선되며 요구사항에 더 유연하게 대응할 수 있는 구조를 갖추게 되었다고 생각한다.

2. 단위 테스트가 용이하도록 리팩토링 (생성자 주입)

위와 같이 개선을 한 후 테스트를 작성하려고 했다. 막상 테스트를 작성하려고 하니 해당 클래스들이 의존하고 있는 객체들 때문에 테스트가 불가능했다.

예를 들어 아래 RedisTransferController의 경우 Redis 클라이언트인 Jedis를 사용하는 RedisSubscriber 클래스와 실제로 파일 이동 작업을 수행하는 TransferService를 로컬 변수로 직접 생성해서 사용하고 있다. 이 때문에 단위 테스트가 어렵고 모든 객체들이 실제로 동작해야만 테스트가 가능했다.

public class RedisTransferController implements TransferController {

    private final TransferConfig config;

    public RedisTransferController(TransferConfig config) {
        this.config = config;
    }

    @Override
    public void run() {
        log.info("Redis Transfer Controller running");

        // 의존 객체를 직접 생성하는 부분
        final TransferService transferService = new TransferService(config.getFileServer());
        final RedisSubscriber redisSubscriber = new RedisSubscriber(config.getRedisOption());
        while (redisSubscriber.isSucceed()) {
            // get redis message's value
            String messageValue = redisSubscriber.messageValue();

        /* 생략 */

    }
}

스케쥴링 기반으로 이미지를 다운로드 하는 작업을 시작해주는 ScheduleTransferController의 경우도 실제 객체를 사용해서 테스트를 하게 되면 마찬가지로 실제 스케쥴러가 동작할 때 까지 기다려야 하므로 스케쥴러의 실행이 정상적으로 수행되지 않을 테스트에서는 동작을 검증하기가 어려웠다.

// ScheduleTransferController
@Override
public void run() {
    log.info("Schedule Transfer Controller running");

    try {
        // 의존 객체를 직접 생성하는 부분
        final CustomScheduler customScheduler = new CronScheduler();
        // add schedule
        parseScheduleConfigs(config);

        /* 생략 */

}

이를 해결하기 위해 스프링을 사용해 개발을 진행할 때 모든 의존관계를 생성자를 통해 받도록 코드를 작성하면 스프링 컨테이너가 DI를 수행하는 것처럼 필요한 의존관계 객체들을 내부에서 직접 생성하는 것이 아니라 외부에서 생성자를 통해 주입해주도록 변경해주었다. 즉, 이전에 팩토리 패턴처럼 사용했던 TransferControllerType enum 클래스가 일종의 DI 컨테이너의 역할을 수행하게 되었다고 볼 수 있을 것 같다. 이렇게 하면 테스트 코드에서 생성자를 통해 실제 의존 객체나 해당 객체를 stubbing, mocking 한 객체를 주입해서 동작을 확인할 수 있으므로 단위 테스트가 가능해지게 된다.

변경된 TransferController 클래스들

public class RedisTransferController implements TransferController {

    private final TransferConfig config;
    private final TransferService transferService;
    private final RedisSubscriber redisSubscriber;

    public RedisTransferController(TransferConfig config, TransferService transferService, RedisSubscriber redisSubscriber) {
        this.config = config;
        this.transferService = transferService;
        this.redisSubscriber = redisSubscriber;
    }

    /* 생략 */

}
public class ScheduleTransferController implements TransferController {

    private final TransferConfig config;
    private final FileServer fileServer;
    private final CustomScheduler customScheduler;

    // config files only for schedule transfer
    private ScheduleConfig scheduleConfig;
    private RemoteFilePathConfig remoteFilePathConfig;

    public ScheduleTransferController(TransferConfig config, CustomScheduler customScheduler) {
        this.config = config;
        this.fileServer = config.getFileServer();
        this.customScheduler = customScheduler; 
    }

    /* 생략 */

}

변경된 TransferControllerType enum 클래스

public enum TransferControllerType {
    // Function 에서 TransferController 구체 클래스를 생성할 때
    // 인자를 직접 생성해서 넣어주는 방식으로 변경
    REDIS((config) -> new RedisTransferController(config, new TransferService(config.getFileServer()), new RedisSubscriber(config.getRedisOption()))),
    SCHEDULE((config) -> new ScheduleTransferController(config, new CustomScheduler()));

    private final Function<TransferConfig, TransferController> transferControllerFactory;

    TransferControllerType(Function<TransferConfig, TransferController> transferControllerFactory) {
        this.transferControllerFactory = transferControllerFactory;
    }

    public static TransferController getInstance(TransferConfig config) {
        return Arrays.stream(values())
                .filter(value -> value.name().equalsIgnoreCase(config.getControllerType()))
                .findAny()
                .map(value -> value.transferControllerFactory)
                .orElseThrow(() -> new IllegalArgumentException("undefined controller type"))
                .apply(config);
    }
}

이렇게 변경하고 나니 테스트에서 다음과 같이 외부에 의존하는 객체들을 stubbing하여 테스트하는 것이 훨씬 간단해졌다.

아래와 같이 RedisTransferController 가 의존하는 객체 TransferConfig, TransferService, RedisSubcriber를 각각 상속받는 XxxStub 객체를 만들었다.

static class TransferConfigStub extends TransferConfig {

    private final String localRootDir;

    public TransferConfigStub(String localRootDir) {
        this.localRootDir = localRootDir;
    }

    @Override
    public String getLocalRootDir() {
        return localRootDir;
    }
}
static class TransferServiceStub extends TransferService {

    public TransferServiceStub() {
        super(new TransferConfig.FileServer());
    }

    private String remoteRootDir;
    private String localRootDir;
    private String remoteFilePath;

    @Override
    public boolean transfer(String remoteRootDir, String localRootDir, String remoteFilePath) {
        this.remoteRootDir = remoteRootDir;
        this.localRootDir = localRootDir;
        this.remoteFilePath = remoteFilePath;

        return true;
    }

    // RedisTransferController에서 TransferService.transfer() 메서드를
    // 정상값으로 호출하는지 검증하기 위해 추가 구현힌 메서드
    public boolean verifyTransfer(String remoteRootDir, String localRootDir, String remoteFilePath) {
        if (!this.remoteRootDir.equals(remoteRootDir)) {
            return false;
        }
        if (!this.localRootDir.equals(localRootDir)) {
            return false;
        }
        return this.remoteFilePath.equals(remoteFilePath);
    }
}
static class RedisSubscriberStub extends RedisSubscriber {

    private final String remoteRootDir;
    private final String remoteFilePath;

    public RedisSubscriberStub(String remoteRootDir, String remoteFilePath) {
        super(new TransferConfig.RedisOption());
        this.remoteRootDir = remoteRootDir;
        this.remoteFilePath = remoteFilePath;
    }

    private boolean isFirstCall = true;

    @Override
    public boolean isSucceed() {
        if (isFirstCall) {
            isFirstCall = false;
            return true;
        }
        return false;
    }

    @Override
    public String messageValue() {
        // generate redis msg value
        JSONArray jsonArray = new JSONArray();
        jsonArray.put(remoteFilePath);

        JSONObject jsonObject = new JSONObject();
        jsonObject.put("remote_root_dir", remoteRootDir);
        jsonObject.put("download_paths", jsonArray);

        return jsonObject.toString();
    }
}

그리고 해당 Stub 클래스들로 RedisTransferController를 생성해 run() 메서드를 호출한다.

@Test
void run메서드를_호출하면_TransferService의_transfer메서드에_정상_값들이_전달된다() {
    // given
    String localRootDir = "/rootDir";
    String remoteRootDir = "/remoteRootDir";
    String remoteFilePath = "/images/image.png";

    TransferConfigStub transferConfigStub = new TransferConfigStub(localRootDir);
    TransferServiceStub transferServiceStub = new TransferServiceStub();
    RedisSubscriberStub redisSubscriberStub = new RedisSubscriberStub(remoteRootDir, remoteFilePath);

    RedisTransferController redisTransferController = new RedisTransferController(
            transferConfigStub,
            transferServiceStub,
            redisSubscriberStub
    );

    // when
    redisTransferController.run();

    // then
    assertThat(transferServiceStub.verifyTransfer(remoteRootDir, localRootDir, remoteFilePath)).isTrue();
}

실제로는 외부에서 Redis를 통해 메시지가 수신되지 않으면 테스트가 항상 실패했겠지만, stubbing 객체를 사용해 메시지를 임의로 반환해주었기 때문에 테스트가 항상 제대로 동작하게 된다.

물론 아래와 같이 @Mock, @InjectMocks 어노테이션을 사용해서 테스트해도 되며 @InjectMocks 어노테이션은 생성자, setter, field 주입을 지원하기 때문에 이 경우에도 값을 외부에서 주입할 수 있도록 분리해주어야 한다.

class RedisTransferControllerTest {

    @Mock
    RedisSubscriber redisSubscriber;
    @Mock
    TransferService transferService;
    @Mock
    TransferConfig transferConfig;

    @InjectMocks
    RedisTransferController redisController;

    @Test
    void run메서드를_호출하면_TransferService의_transfer메서드에_정상_값들이_전달된다_mock어노테이션_사용() {
        // given
        when(transferConfig.getLocalRootDir()).thenReturn("/rootDir");
        when(redisSubscriber.isSucceed()).thenReturn(true, false);

        String remoteRootDir = "/remoteRootDir";
        String localRootDir = transferConfig.getLocalRootDir();
        String remoteFilePath = "/images/image.png";

        JSONObject jsonObject = createJsonObject(remoteFilePath, remoteRootDir);
        when(redisSubscriber.messageValue()).thenReturn(jsonObject.toString());

        // when
        redisController.run();

        //then
        verify(transferService).transfer(remoteRootDir, localRootDir, remoteFilePath);
    }
}

지금은 private final + 생성자 주입 패턴이 당연하게 받아들여지지만, 인턴을 할 당시에는 그런 코드들이 낯설었다. 그래서 오직 테스트를 위해 프로덕션 코드를 막 변경하는게 옳은 것인지에 대한 고민도 많았었다. 그때 이 글 (테스트하기 좋은 코드 - 테스트하기 어려운 코드)을 보고 내가 작성한 코드에 문제가 있다는 사실을 발견할 수 있었다. 이 이후로 코드를 작성하는 마음가짐이 정말 많이 달라지게 되었다.

3. 설정 파일 분리 & 중복 제거

마지막으로 정리하고 싶은 내용은 처음에 앞서 개발했다고 했던 api를 통해 위성 사진을 주기적으로 다운로드 받는 스케쥴러 모듈 개발과 관련된 내용이다. 이 모듈을 개발하기 위해 api를 호출해 사진을 다운로드 하는 서비스를 개발하고, quartz 스케쥴러의 Job 인터페이스를 구현해 주기적으로 서비스를 호출하여 사진을 저장하도록 구현하고자 했다.

이때 문제는 다운로드 받아야 하는 사진이 많고, 그룹별로 url을 구성하는 규칙과 구조가 조금씩 달랐다는 점이다. 예를 들면 아래와 같은 식이다.

6개 정도의 group이 있고, group들은 site를 각각 3-6개 정도씩 가지고 있고 site는 다시 type을 4개정도씩 가지고 있었다. 그리고 위 표에서 볼수있듯 각 그룹별, 타입별로 url을 만들어내는 규칙이 서로 달랐다. 그래서 이를 깔끔하게 코드로 구현할 방법을 찾기가 어려웠다.

우선 이 url들을 설정 파일로 분리하고 외부에서 주입해줘야겠다는 생각이 들어 아래와 같이 yml 파일을 작성했다. 그리고 yml 파일의 주소를 프로그램 실행 시 인자로 전달하여 자바 코드와 무관하게 외부에서 스크랩 할 이미지의 url을 전달할 수 있도록 했다.

cloud:
    seoul_top_daily: 
        urlFormat: "'https://abc.com/cloud/seoul?type=top&date='yyyyMMdd"
        savePathFormat: "'/home/documents/scrapper/result/images/cloud/seoul/top/'yyyy/MM'/cloud_seoul_top_daily_'yyyyMMdd'.jpg"
    seoul_top_monthly: 
        urlFormat: "'https://abc.com/cloud/seoul?type=top&date='yyyyMM"
        savePathFormat: "'/home/documents/scrapper/result/images/cloud/seoul/top/'yyyy/MM'/cloud_seoul_top_monthly_'yyyyMM'.jpg"
    ...:
sky:
    pangyo_left_daily:
        urlFormat: "'https://abc.com/sky/pangyo/left/'yyyy/MM'/daily/'yyyyMMdd-yyyyMMdd"
        savePathFormat: "'/home/documents/scrapper/result/images/sky/pangyo/left/'yyyy/MM'/sky_pangyo_left_daily_'yyyyMMdd'.jpg"
    pangyo_left_monthly:
        ...:
...:                

자바 코드에서는 이를 ObjectMapper로 읽어와 타겟 날짜로 문자열을 포맷팅한 후 api를 호출하는 서비스에 전달해주었다.

// 타겟 날짜로 url 문자열 포맷팅
private String formatUrl(String urlFormat, LocalDate targetDate) {
    DateTimeFormatter formatter = DateTimeFormatter.ofPattern(urlFormat);
    return formatter.format(targetDate);
}

처음에는 이렇게 개발을 완료했고 동작도 잘해서 됐다..! 라고 생각했는데 페어프로그래밍을 통한 코드 리뷰 과정에서 이렇게 작성된 코드의 문제를 인지할 수 있었다.

현재 코드의 문제는 우선 외부에서 주입해주는 설정 파일의 urlFormat, savePathFormat에 중복이 많다는 점과 아래와 같은 구조의 클래스로 yml파일을 매핑하기 때문에 수집하려는 데이터의 url변경에는 대응할 수 있지만 수집 데이터 자체가 변경되거나 추가되면 해당 데이터에 맞게 yml파일 뿐만 아니라 자바 코드도 수정해야 한다는 점이다.

// 최초에 yml 파일을 매핑했던 클래스
@Data
public class DownloadConfig {

    private Cloud cloud;
    private Sky sky;
    // ...

    @Data
    public static class Cloud {
        private Cycle seoulTopDaily;
        private Cycle seoulTopMonthly;
        // ...
    }

    @Data
    public static class Sky {
        private Cycle pangyoLeftDaily;
        private Cycle pangyoLeftMonthly;
        // ...
    }

    @Data
    public static class Cycle {
        private String urlFormat;
        private String savePathFormat;
    }

}

이를 해결하기 위해 먼저 yaml 설정 파일의 구조를 계층 구조를 적극 활용하도록 다음과 같이 변경해주었다. 그리고 type의 경우 Cycle인 daily, monthly의 하위에 리스트로만 넣어주었다.

savePathRoot: "'/home/documents/scrapper/result/images/"
groups:
    cloud:
        seoul:
            daily:
                urlFormat: "'https://abc.com/cloud/seoul?type=#type&date='yyyyMMdd"
                savePathFormat: "cloud/seoul/#type/'yyyy/MM'/cloud_seoul_#type_daily_'yyyyMMdd'.jpg"
                types: ["top", "bottom", "middle", "side"]
            monthly:
                urlFormat: "'https://abc.com/cloud/seoul?type=#type&date='yyyyMM"
                savePathFormat: "cloud/seoul/#type/'yyyy/MM'/cloud_seoul_#type_monthly_'yyyyMM'.jpg"
                types: ["top", "bottom", "middle", "side"]
        ...:
    sky:
        pangyo:
            daily:
                urlFormat: "'https://abc.com/sky/pangyo/#type/'yyyy/MM'/daily/'yyyyMMdd-yyyyMMdd"
                savePathFormat: "sky/pangyo/#type/'yyyy/MM'/sky_pangyo_#type_daily_'yyyyMMdd'.jpg"
                types: ["left", "right", "zero"]
            ...:
        ...:
    ...:    

최초에 구조에서 가장 하단에 있는 cycle 별로 모든 데이터를 적어줬던 것에 비하면 훨씬 간결한 구조를 가져갈 수 있게 되었다. (여기서 type이 cycle보다 상위 계층임에도 cycle 하위로 묶은 이유는 이미지를 다운로드해야 하는 url 생성 규칙을 모두 확인해봤을 때 type은 url에 영향을 주지 않고 group, site와 cycle만이 영향을 주었기 때문에 group -> site -> cycle까지만 확인하고 type은 해당 조건 내에서 순회하도록 해도 충분히 url을 생성해낼 수 있었기 때문이다.)

urlFormat과 savePathFormat에 #type 형태로 치환자를 넣어 types의 원소를 이용해 url, savePath를 생성할 수 있게 했고, savePath에 중복되던 상위 디렉토리도 savePathRoot 로 분리하여 더 간결하게 설정 파일을 작성할 수 있게 수정했다.

매핑 클래스도 다음과 같이 수정되었다. 매 데이터 별로 별도의 필드가 필요했던 이전 코드에 비해 중복이 상당히 줄어들었다.

@Data
public class DownloadConfig {

    private String savePathRoot;
    private Map<String, Map<String, Site>> groups;

    @Data
    public static class Site {
        private Cycle daily;
        private Cycle monthly;

        // daily, monthly 중 하나만 가지고 있는 데이터도 존재하기 때문에 사용
        public List<Cycle> getCycles() {
            List<Cycle> cycles = new ArrayList<>();

            if (daily != null) {
                cycles.add(daily);
            }
            if (monthly != null) {
                cycles.add(monthly);
            }

            return cycles;
        }
    }

    @Data
    public static class Cycle {
        private String urlFormat;
        private String savePathFormat;
        private List<Type> types;
    }

    @Data
    public static class Type {
        private static final String TYPE_REPLACEMENT_VALUE = "#type";

        private String value;

        @JsonValue
        public String getValue() {
            return value;
        }

        @JsonCreator
        public Type(String value) {
            this.value = value;
        }

        public String formatUrl(String urlFormat, LocalDate targetDate) {
            DateTimeFormatter formater = DateTimeFormatter.ofPattern(urlFormat);
            return formater.format(targetDate);
        }

        public String formatSavePath(String savePathFormat, LocalDate targetDate) {
            String typeResolvedSavePath = savePathFormat.replace(TYPE_REPLACEMENT_VALUE, value);
            DateTimeFormatter formater = DateTimeFormatter.ofPattern(typeResolvedSavePath);
            return formater.format(targetDate);
        }
    }

}

설정 파일이 계층 구조로 변경되며 값을 Map에 담을 수 있게 되었다. 덕분에 기존과 달리 수집해야 하는 새로운 데이터가 추가되어도 자바 코드는 아무런 변경 없이 yaml 파일만 수정하면 스케쥴러가 새로운 데이터를 문제없이 수집하도록 구조가 개선되었다. 또한, url과 savePath를 포맷팅하는 메서드도 Type 클래스에 넣어 데이터를 가지고 있는 객체에 책임을 분배해주었다.

애플리케이션을 실행하면 다음과 같은 코드를 통해 설정 파일을 읽어와 Map을 순회하면서 별도의 스케쥴링 설정 파일에서 읽어온 스케쥴 정보와 함께 스케쥴러에 전달한다.

private void registerSchedule(CustomScheduler scheduler) {
    downloadConfig.getGroups().keySet().forEach(group -> {
        downloadConfig.getGroups().get(group).keySet().forEach(site -> {
            // make jobData by each group-site
            Map<String, ?> jobData = new HashMap<>() {{
                put("SAVE_PATH_ROOT", downloadConfig.getSavePathRoot());
                put("GROUP_NAME", group);
                put("SITE_NAME", site);
                put("SITE_CONFIG", downloadConfig.getGroups().get(group).get(site));
            }};

            // schedule registration
            String trigger = scheduleConfig.getGroups().get(group).get(site).getSchedule();
            scheduler.register(trigger, jobData, ScheduleTransferJob.class);
            log.info("Schedule registration completed. groupName: {}, siteName: {}", group,
                site);
        });
    });
}

마지막으로 스케쥴러는 SITE_CONFIG 정보를 사용해 일정 주기로 다음 코드를 통해 url과 savePath를 생성하며 이미지 다운로드를 수행한다.

// daily와 monthly를 하나의 리스트로 병합
List<Cycle> cycles = site.getCycles();

// url와 savePath를 포맷팅하고 스크랩 서비스를 호출
LocalDate now = LocalDate.now();
cycles.forEach(cycle -> {
    cycle.getTypes().forEach(type -> {
        String formattedUrl = type.formatUrl(cycle.getUrlFormat(), now);

        String formattedSavePath = type.formatSavePath(cycle.getSavePathFormat(), now);
        String combinedSavePath = combinedPath(savePathRoot, formattedSavePath);

        scrapService.scrap(formattedUrl, combinedSavePath);
    });
});

스케쥴 실행 로그

코드에 대한 자신감

위에 정리한 내용 이외에도 인턴 과정에서 코드를 어떻게 작성해야 하는지에 대해 많은 것들을 배울 수 있었다. 이 과정에서 가장 좋았던 것은 스스로 내 코드에 자신감을 가질 수 있게 되었다는 점이다. 이전까지는 내 코드에 대해 다른 사람의 피드백을 받아볼 기회가 없었기 때문에 코드를 작성했어도 이 코드가 좋은 코드인지 아닌지에 대한 기준 자체를 세우기가 어려웠다. 인턴 과정에서 코드 리뷰를 받으며 지금은 적어도 좋은 코드를 작성하려는 마음가짐을 갖게 되었고 좋은 구조에 대한 안목을 기를 수 있게 되어 좋았다.

또한 코드를 작성하고 추상화, SOLID, DI 등을 고려하며 코드를 리팩토링하다보니 아주 간단한 버전이지만 스프링과 유사한 구조를 만들어내고 있는 것 같았고, (회사에서 개발한 모듈은 스프링을 사용하고 있지 않았음에도) 이를 통해 다시 스프링의 철학과 구조를 조금 더 깊게 이해할 수 있게 되어서 신기한 경험이었다.

개발 외적인 경험

이전에 한국전자통신연구원에서 인턴으로 일해보긴 했었지만 그때는 개발보다는 자료 조사 위주였기 때문에 개발을 직접 하는 인턴은 이번이 처음이었는데 과제나 개인 프로젝트로 개발을 할 때는 고려하지 않았던, 실무 개발에서 고려해야 하는 것들을 직접 해볼 수 있어서 너무 좋은 경험이었다. 예를 들면 로깅을 위해 logback의 appender 설정 하는 것이나 내부망 모듈 개발을 위해 네트워크 설정을 내부망 환경과 동일하게 구성해보는 것, 개발한 모듈을 도커 이미지로 만들어 k8s 환경에 배포하는 것 등 여러 가지 실무 경험들을 해볼 수 있어서 너무나 소중한 시간이었다.

또한 사수님이 인턴 시작 때 부터 모르는게 있으면 무조건 질문하라고 질문하기 편한 환경을 만들어주셔서 최대한 고민하고 모르겠는 것들은 주저없이 여쭤볼 수 있었다. 이 과정에서 좋은 질문 방법이나 어떤 상황에서 질문하면 좋을지 등 막연했던 회사에서의 커뮤니케이션 방법들을 익힐 수 있어 많은 도움이 되었다. 매일 점심 식사를 하고 팀원분들과 카페를 가는 문화(?) 같은게 있었는데 팀원분들도 모두 잘 대해주셔서 회사 생활이나 개발에 관련해 많이 여쭤보고 답을 들을 수 있었다.

후회되는 점

반면 인턴이 끝날 때 쯤 후회되는 점도 몇 가지 있었다.

자바에 대한 지식 부족

가장 크게 아쉬웠던 점은 인턴을 했을 시점에 내가 자바에 대한 기초 지식이 부족하다는 점이었다. 이때까지는 매번 작성하던 유사한 수준의 코드만 복사/붙여넣기 하며 작성했기 때문에 자바의 구조나 문법을 깊게 공부해야겠다는 생각을 못했었다. 그래서 사수님이 작성해주신 코드를 보는데 제네릭, 스트림 등 모르는 (사실 인턴 이전에도 보긴했지만 모른척하고 넘어갔던,,) 문법이 많아 이해하는데 어려움을 겪었다. (특히 yaml 파싱 시 타겟 클래스를 Class<?> 타입 인자로 전달하는 부분에서 굉장한.. 충격을 받았었다.) 자바 백엔드 개발자로 성장하려고 하는데 주력 언어에 대해 아는게 별로 없다는 생각에 창피했지만 덕분에 공부해야겠다는 다짐과 동기부여를 제대로 얻을 수 있었다.

그래서 인턴이 끝나고 올해 9월부터 진행했던 NEXTSTEP의 TDD, 클린 코드 Java 15기 과정에 참여해서 자바 문법과 클린 코드에 대한 경험을 쌓았다. 그리고 다음 달부터는 백기선님의 자바 기초 스터디 커리큘럼을 따라하는 스터디 그룹도 만들어 계속해서 자바와 스프링에 대한 공부를 해나갈 계획이다.

기록하는 습관

인턴을 하면서 개발을 하며 기록하는게 정말 중요하다는 걸 깨달았다. 개발 도중에 발생하는 수많은 오류와 학습 내용들을 기록해두지 않고 1주, 2주가 지나니 아무것도 기억나는게 없었다. 공부하고 경험한 것들을 누적해가며 성장하기 위해서는 그때그때 기록을 해두는 것이 굉장히 중요하다는 걸 알게되었고 인턴을 시작할 때부터 이러한 습관을 들이지 못한게 아쉬웠다. 또한 개발 과정을 기록해두지 않으니 이후에 다른 곳에서 또 다른 오류가 발생했을 때 코드를 수정하기 위해 다시 코드를 이해하고 분석하는데 시간을 들이게 되었다. 인턴이 끝난 이후에는 기록하기 쉽고 다시 보기 편한 나에게 맞는 플랫폼을 선택해서 개발하는 내용들을 기록하는 습관을 들이려 노력하고 있다.

정리

짧은 시간이었지만 첫 인턴이었기에 정말 많은 것들을 배울 수 있었다. 그전까지 막연하게 개발 공부를 해왔는데 인턴을 통해 무엇을 공부해야 하고 어떤 것들이 중요한지 알게되었다. 자바 기초가 부족함을 느끼게 해주어 NEXTSTEP 코스나 자바 스터디를 시작하게 된 계기가 되었고, 코드를 그냥 작성하는게 중요한게 아니라 유지보수를 고려해 어떻게 코드를 작성해야 하는지도 알게되었다. 개발 외적으로는 무엇을 더 알아야 하는지, 현업에서는 코드를 어떻게 관리하는지 등에 대해서도 배울 수 있었다.

내가 해오던 것들 중에 잘 해오고 있던 것과 부족했던 것들을 정비할 수 있었던 좋은 시간이었던 것 같다. 인턴을 하며 배운점과 느낀점들을 잘 간직하며 내년 여름에는 인턴을 시작하기 전보다 더 많이 성장해있을 수 있도록 노력할 것이다.