DKim Devlog
HomeDevlogProjectsAbout Me
Profile

Daehwan Kim

Backend Developer

개발 참 즐겁습니다. 공식문서 읽는 것을 즐깁니다. 배드민턴 치는 것을 좋아합니다.

Status
Open to work

Documentation - driven Developer

공식 문서 속의 원리를 탐구하고 기록하며, 정석을 바탕으로 지식을 나누는 과정을 통해 성장을 넘어 팀의 발전에 기여하고자 합니다.

Live Feed

Latest Devlog

Display all

Archive

Project

View All
2025.09 - 2026.03

RallyOn

RallyOn은 배드민턴 자유게임 운영에 필요한 로그인, 프로필 설정, 장소 검색, 세션 생성, 참가자 관리, 경기 편성, 공유 조회 기능을 제공하는 서비스입니다.

Spring Boot
Spring Security
JPA (Hibernate)
PostgreSQL
Flyway
2025.09 - 2025.11

Everp

현대오토에버 모빌리티 코딩 스쿨에서 현대오토에버 ERP 시스템 개발을 목표로 진행한 대규모 팀 프로젝트에서, DDD 기반 MSA 서버 경계를 설계하고 ERP 도메인과 유비쿼터스 언어를 정리한 뒤 Auth와 Gateway 권한 처리 구조를 구현한 프로젝트입니다.

Spring Boot
Spring Security
Spring Cloud Gateway
PostgreSQL
JPA (Hibernate)
2025.06 - 2025.07

영화 추천 커뮤니티 플랫폼

사용자의 리뷰 텍스트를 AI 감정분석으로 처리하여 개인의 취향에 맞는 영화를 큐레이션해주는 맞춤형 추천 커뮤니티 서비스입니다.

Spring Boot
Spring Security
JPA (Hibernate)
FastAPI
MySQL
2026년 3월 13일

[TDD] TDD 해보자!

서론 AI 코딩이 판을 치는 이 개발자 시대에서 언뜻 회의를 조금 느꼈다. 인생의 여러 갈래에서 개발자가 되고자 한 이유는 내 손으로 직접 프로덕트를 만들 수 있다는 점 Coding이라는 행위 자체의 즐거움 이 두 가지였다. 그런데 개발자 취업 준비를 하면서, 이 두 가지는 더 쉬워졌는데 나에게는 더욱 공허함이 생겼다. 이러한 개발 트렌드에서 최근 많은 사람들이 이제는 개발자가 코드를 짜지 않고 마크다운 문서를 작성하는데 더욱 힘을 쏟고, 이제 바이브 코딩이 아닌 하네스 코딩시대로 흘러가고 있다. 여기서 하네스 코딩이라는 것은 규칙과 제약으로 AI 코딩을 수행하는 것이다. 그러면서, 나는 현 시대에서 개발자에게 가장 중요한 것은 다음이 아닐까라고 생각이 들었다. 아키텍트가 되기 테스트 코드 검증하기 내부 동작 원리 파악하기 도메인 문서 파악하기 도메인 문서 파악하기는 나의 개발 모토인데, 나는 도메인이 아닌 문서가 중심인 DDD(Document Driven Development)를 나의 개발 모토로 가지고 있고 개발을 잘하기 위해, 좋은 개발자가 되기 위해 많은 공식 문서를 읽고 있다. 그래서 오늘 다루고 싶은 주제는 테스트 코드 작성법에 대해 공부하고 적용했던 것을 공유해보고자 한다. TDD, 켄트 백 너무 잘 알고 있겠지만, TDD(Test Driven Development)라는 개발 방법론은 켄트백이 제안한 것이다. 기존에 테스트를 먼저 작성하는 방식(test-first)라는 것은 TDD 이전에 이미 있었다고 하지만, 이를 하나의 개발 방법론으로 제안한 것은 켄트백의 Extreme Programming Explained(1999) 그리고 Test-Driven Development: By Example(2002)이다. TDD의 목적은 "clean code that works"를 만드는 것으로 테스트를 많이 작성하는 것이 아니라, 작은 피드백 루프로 설계가 성장하도록 하고, 변경에 대한 공포를낮추고, 자신 있게 다음 변경을 할 수 있도록 하는 것이다. 그래서 Test Code는 목적을 달성하게 하는 도구이고,Red -> Green -> Refactor의 순환 패턴은 테스트 코드를 작성하는 패턴이 아니라, 개발 사이클 전체의 패턴이다. 따라서 테스트만 따로 사용하고 끝내는 것이 아니라 실패하는 테스트를 작성하고 프로덕션 코드를 최소한으로 구현하고 구조를 정리한 뒤 다시 다음 작은 테스트로 넘어간다. 작은 단위의 애자일 개발과 비슷한 방식이다. 예시로 다음과 같다. 닉네임이 없으면 예외 발생 닉네임이 있으면 저장 성공 중복 닉네임이면 충돌 발생 TDD, Demo 나는 TDD에 대해 공부하고 내 사이드 프로젝트에 적용하면서 다음 2가지의 목표를 정했다. ** 1. clean code that works** ** 2. Red -> Green -> Refactor** 그러나 위 2가지 원칙과 목표로 TDD 기반 애플리케이션 개발을 하는데, 실제 TDD를 해보니까 마냥 TDD의 개념처럼 단순하지 않았다. TDD의 본질은 동일한데, 테스트의 단위가 달라진다. 먼저, 아키텍처 구조에 따라 테스트의 분리가 달라진다. 레이어드 아키텍처 보통 controller / service / repository 같은 레이어 단위를 가지므로 이와 동일한 단위로 테스트를 분리한다. 그래서 ServiceTest, ControllerTest, RepositoryTest가 자연스러운 형태가 된다. 클린 아키텍처(헥사고날 아키텍처) 보통 use case / port / adapter 기준으로 테스트를 나눈다. 핵심 비즈니스 규칙은 프레임워크 없이 use case 테스트로 먼저 잡고 DB, HTTP, 외부 OAuth 같은 건 adapter 테스트로 따로 검증한다. 즉 테스트가 기술 레이어보다 행동 경계를 더 강하게 반영한다. 그래서 위 2가지 구조에서는 다음과 같은 차이점이 생긴다. 레이어드 아키텍처 이 서비스 메서드가 repository.save를 호출했는가? 이 컨트롤러가 200/400을 반환하는가? 클린(헥사고날) 아키텍처 이 유스케이스가 어떤 입력에서 어떤 비즈니스 결과를 내는가? 포트 구현체(adapter)가 DB/API와 올바르게 연결되는가? 즉, 레이어드: 기술 계층 중심의 테스트 구조를 가짐 클린: 핵심 로직 테스트와 기술 통합 테스트로 분리됨. 그런데, 테스트를 자르는 단위(기준)은 다르지만 테스트 작성 원리는 동일하다. 그래서 오늘 다루고 싶은 내용은 먼저 내가 레이어드 아키텍처를 중심으로 사이드 프로젝트를 어떻게 수행했는지, 데모 코드로 보여주고자 한다. 레이어드 아키텍처 먼저 레이어드 아키텍처에 대해 잘 모르는 사람에게 간단하게 설명하자면, 애플리케이션을 역할별 층(layer)로 나눈 구조이다. 보통 왼쪽에서 오른쪽으로 Controller -> Service -> Repository -> Database의 흐름을 가지며, 각각은 다음 역할을 한다. Controller: HTTP 요청/응답 처리 Service: 비즈니스 로직 처리 Repository: DB 접근 담당 Database: 실제 데이터 저장소 레이어드 아키텍처의 목적은 위 구조로 요청 처리, 비즈니스, DB 접근에 대한 관심사 분리이다. 레이어드 아키텍처에서의 TDD 위에서도 설명했듯이 레이어드 아키텍처에서의 TDD는 다음과 같은 기준을 가지면 된다. Controller HTTP 요청/응답, validation, status code, error body 비즈니스 규칙 테스트를 여기서 하지 않는다. Service TDD의 중심으로 비즈니스 규칙, 상태 변경, 예외 흐름을 여기서 고정한다. Repository DB 매핑, query method, persistence 동작만 검증한다. 규칙을 여기서 검증하지 않는다. 즉, 레이어드 아키텍처에서의 TDD는 service 중심으로 이루어진다고 보면 된다. 그래서 나는 다음과 같은 순서로 진행했다. service test 고정 red -> green: 최소 service 코드로 작동하는 코드 작성 service 의존 repository는 mock으로 설정 controller test로 HTTP 계약 고정 필요하면 repository test로 DB 동작 검증 그리고 Spring Boot 환경에서 수행했으므로, 테스트는 JUnit 5, Mockito, MockMvc, @DataJpaTest, H2를 기준으로 구성했다. BDD 이번 데모에서 또 한 가지 다루었던 방법으로 테스트 코드를 given / when / then 구조로 작성했다. BDD는 Behavior-Driven Development를 의미하고 TDD와 완전히 다른 방법론이라기보다, 테스트를 행동 중심으로 정리해서 작성하는데 도움이 되서 나는 이 방식으로 테스트 코드를 작성하고 있고 테스트 패키지도 지원하고 있다. BDD의 given / when / then은 각각 다음을 의미한다. given: 어떤 상황이 주어졌는가? when: 어떤 행동을 수행하는가? then: 어떤 결과가 나와야하는가? 이런 구조로 테스트를 나누면, 테스트가 단순히 메서드를 호출하는 코드가 아니라 하나의 요구사항처럼 읽히게 된다. 따라서 이번 글에서는 TDD의 개발 사이클(Red -> Green -> Refactor) 위에서 BDD 스타일 표현(given / when / then)을 함께 사용했다고 보면 된다. Domain 이번 데모에서 다루는 도메인은 Profile 생성이다. 즉, 사용자가 프로필 생성 요청을 보내면 시스템이 유효한 프로필을 만들고 저장하는 흐름을 중심으로 TDD를 진행했다. 이번 도메인에서 먼저 고정한 핵심 규칙은 다음과 같다. 유효한 입력이면 프로필이 생성되어야 한다. email, nickname, passwordHash, region, grade, ageGroup, gender는 필수값이어야 한다. 이미 사용 중인 email이면 프로필 생성이 실패해야 한다. tag는 사용자가 입력하는 값이 아니라 서버가 생성하는 값이다. tag는 4자리 문자열이며, 중복되지 않아야 한다. 이를 기준으로 Profile 엔티티의 필드를 다음과 같이 정의했다. 필드 타입 생성 요청 포함 필수 여부 설명 id UUID 아니오 시스템 관리 JPA에서 UUID로 생성되는 식별자 email String 예 예 프로필의 고유 식별 이메일, 중복 불가 nickname String 예 예 사용자 표시명 passwordHash String 예 예 원문 비밀번호가 아니라 해시된 값 profileImageUrl String 예 아니오 프로필 이미지 URL tag String 아니오 예 서버가 생성하여 할당하는 4자리 프로필 태그, 중복 불가 region Region 예 예 사용자 지역 정보 (SEOUL ~ JEJU) grade Grade 예 예 사용자 등급 (E, D, C, B, A, S, SS) ageGroup AgeGroup 예 예 연령대 (TEENS, TWENTIES, THIRTIES, FORTIES, FIFTIES, SIXTIES) gender Gender 예 예 성별 (MALE, FEMALE) createdAt LocalDateTime 아니오 시스템 관리 엔티티 최초 저장 시 자동 기록 updatedAt LocalDateTime 아니오 시스템 관리 엔티티 수정 시 자동 갱신 테스트 환경과 진행 방식 이번 데모는 Spring Boot 4.0.3, Java 21 환경에서 진행했다. 그리고 레이어드 아키텍처를 기준으로 service -> controller -> repository 순서로 테스트를 확장했다. 먼저 사용한 주요 의존성은 다음과 같다. dependencies { implementation 'org.springframework.boot:spring-boot-h2console' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-webmvc' runtimeOnly 'com.h2database:h2' testImplementation 'org.springframework.boot:spring-boot-starter-data-jpa-test' testImplementation 'org.springframework.boot:spring-boot-starter-validation-test' testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' } 각 의존성은 다음 역할을 한다. spring-boot-starter-webmvc 컨트롤러와 HTTP 요청/응답 처리 spring-boot-starter-validation @Valid 기반 요청 검증 spring-boot-starter-data-jpa JPA 엔티티와 Repository 구성 h2 테스트용 인메모리 데이터베이스 spring-boot-starter-webmvc-test @WebMvcTest, MockMvc를 이용한 컨트롤러 테스트 spring-boot-starter-data-jpa-test @DataJpaTest를 이용한 리포지토리 테스트 JUnit 5 테스트 실행 프레임워크 Mockito 서비스 테스트와 컨트롤러 테스트에서 의존성 분리 이번 데모는 켄트 백의 Red -> Green -> Refactor 흐름에 맞춰 진행했다. 처음부터 모든 계층을 한 번에 구현하는 것이 아니라, 먼저 서비스 테스트로 핵심 규칙을 고정하고, 최소 구현으로 통과시킨 뒤, 그 다음 컨트롤러 테스트로 HTTP 계약을 고정하고, 마지막으로 리포지토리 테스트에서 실제 DB 동작을 검증하는 방식으로 가져갔다. 즉, 이번 데모의 흐름은 다음과 같다. ProfileServiceTest로 비즈니스 규칙 고정 ProfileServiceImpl 최소 구현 ProfileControllerTest로 HTTP 계약 고정 ProfileRepositoryTest로 query method와 persistence 동작 검증 이 순서를 선택한 이유는 레이어드 아키텍처에서 핵심 도메인 규칙이 service에 가장 많이 모이기 때문이다. 서비스 테스트 서비스 테스트는 ProfileServiceTest를 중심으로 작성했다. 여기서는 Spring Context를 띄우지 않고 JUnit 5 + Mockito 조합으로 순수 단위 테스트를 수행했다. 사용한 구성은 다음과 같다. @ExtendWith(MockitoExtension.class) JUnit 5 환경에서 Mockito를 사용하기 위한 확장 설정 @Mock ProfileRepository, ProfileTagGenerator를 mock으로 대체 @InjectMocks ProfileServiceImpl에 mock 의존성을 주입 서비스 테스트의 목적은 다음 규칙을 먼저 고정하는 것이었다. 유효한 입력이면 프로필 생성에 성공해야 한다. 필수값이 비어 있으면 생성이 실패해야 한다. 이미 사용 중인 이메일이면 생성이 실패해야 한다. 태그는 서버가 생성하며, 중복되면 안 된다. 예를 들어 성공 케이스는 다음과 같이 작성했다. @ExtendWith(MockitoExtension.class) class ProfileServiceTest { @Mock private ProfileRepository profileRepository; @Mock private ProfileTagGenerator profileTagGenerator; @InjectMocks private ProfileServiceImpl profileService; @Test @DisplayName("유효한 입력이면 프로필 생성에 성공한다") void createProfile_validInput_success() { // given String validEmail = "myEmail@email.com"; CreateProfileRequest request = createRequest(validEmail); given(profileTagGenerator.generate()).willReturn("AB12"); given(profileRepository.existsByTag("AB12")).willReturn(false); given(profileRepository.save(any(Profile.class))) .willAnswer(invocation -> invocation.getArgument(0)); // when Profile profile = profileService.create(request); // then assertEquals(validEmail, profile.getEmail()); assertEquals("myNickname", profile.getNickname()); assertEquals("AB12", profile.getTag()); then(profileRepository).should().save(any(Profile.class)); } } 이 테스트에서 중요한 것은 단순히 “생성 성공”만 보는 것이 아니라, 실제로 tag가 서버에서 생성되어 엔티티에 할당되었는지까지 함께 검증했다는 점이다. 반대로 실패 케이스는 다음과 같이 고정했다. @Test @DisplayName("중복 이메일이면 save()가 호출되지 않는다.") void createProfile_duplicateEmail_doNotSave() { // given String duplicatedEmail = "myEmail@email.com"; CreateProfileRequest request = createRequest(duplicatedEmail); given(profileRepository.existsByEmail(duplicatedEmail)) .willReturn(true); // when & then assertThrows(IllegalArgumentException.class, () -> profileService.create(request)); then(profileRepository).should(never()).save(any(Profile.class)); } 즉, 서비스 테스트에서는 “예외가 발생하는가”만 보는 것이 아니라, 실패했을 때 저장이 실제로 일어나지 않았는지까지 함께 고정했다. 이 테스트를 통과시키기 위한 실제 서비스 구현은 다음과 같다. @Service @Transactional @RequiredArgsConstructor public class ProfileServiceImpl implements ProfileService { private final ProfileRepository profileRepository; private final ProfileTagGenerator profileTagGenerator; @Override public Profile create(CreateProfileRequest request) { validateRequiredFields(request); validateDuplicateEmail(request.email()); String tag = generateUniqueTag(); Profile profile = Profile.create( request.email(), request.nickname(), request.passwordHash(), request.profileImageUrl(), tag, request.region(), request.grade(), request.ageGroup(), request.gender() ); return profileRepository.save(profile); } private String generateUniqueTag() { do { String tag = profileTagGenerator.generate(); if (!profileRepository.existsByTag(tag)) { return tag; } } while (true); } } 즉, 서비스 테스트는 외부 기술보다 “비즈니스 규칙과 상태 변화”를 먼저 고정하는 데 집중했다고 보면 된다. 태그 생성기 테스트 이번 도메인에서는 tag가 사용자가 입력하는 값이 아니라 서버가 생성하는 값이다. 그래서 서비스 테스트 외에도 생성 규칙 자체를 별도의 단위 테스트로 분리했다. 이 테스트는 RandomProfileTagGeneratorTest에서 검증했다. 현재 프로젝트에서는 tag를 대문자 영어 + 숫자 조합의 4자리 문자열로 생성한다. class RandomProfileTagGeneratorTest { private final RandomProfileTagGenerator tagGenerator = new RandomProfileTagGenerator(); @Test @DisplayName("생성된 태그는 4자리이다.") void generate_returnsFourCharactersTag() { String tag = tagGenerator.generate(); assertEquals(4, tag.length()); } @Test @DisplayName("생성된 태그는 대문자 영어와 숫자로만 구성된다.") void generate_returnsUppercaseLettersAndDigitsOnly() { String tag = tagGenerator.generate(); assertTrue(tag.matches("[A-Z0-9]{4}")); } } 즉, 서비스는 “태그를 어떻게 사용할 것인가”를 담당하고, 생성기는 “태그 형식 규칙”을 담당하도록 책임을 분리했다. 컨트롤러 테스트 컨트롤러 테스트는 ProfileControllerTest를 기준으로 작성했다. 여기서는 전체 애플리케이션을 띄우지 않고 MVC 계층만 검증하기 위해 @WebMvcTest와 MockMvc를 사용했다. 사용한 구성은 다음과 같다. @WebMvcTest 웹 계층만 로드하는 테스트 슬라이스 MockMvc 실제 HTTP 요청처럼 컨트롤러 호출 @MockitoBean 컨트롤러가 의존하는 ProfileService를 mock으로 등록 ObjectMapper 요청 DTO를 JSON 문자열로 변환 컨트롤러 테스트의 핵심은 비즈니스 규칙을 다시 테스트하는 것이 아니라, HTTP 계약을 고정하는 것이다. 즉, 정상 요청이면 어떤 상태 코드를 주는가 서비스에 요청을 위임하는가 잘못된 요청이면 어떤 상태 코드를 주는가 이 3가지를 보는 데 집중했다. 성공 케이스는 다음과 같이 작성했다. @WebMvcTest class ProfileControllerTest { @Autowired private MockMvc mockMvc; @Autowired private ObjectMapper objectMapper; @MockitoBean private ProfileService profileService; @Test @DisplayName("유효한 요청이면 201을 반환한다.") void createProfile_validRequest_returnCreated() throws Exception { // given CreateProfileRequest request = new CreateProfileRequest( "myEmail@email.com", "myNickname", "HashedPassword", "myProfileImageUrl", Region.SEOUL, Grade.D, AgeGroup.TWENTIES, Gender.MALE ); String requestBody = objectMapper.writeValueAsString(request); Profile profile = Profile.create( "myEmail@email.com", "myNickname", "HashedPassword", "myProfileImageUrl", "AB12", Region.SEOUL, Grade.D, AgeGroup.TWENTIES, Gender.MALE ); given(profileService.create(any())).willReturn(profile); // when & then mockMvc.perform(post("/profiles") .contentType(MediaType.APPLICATION_JSON) .content(requestBody)) .andExpect(status().isCreated()); then(profileService).should().create(any(CreateProfileRequest.class)); } } 즉, 이 테스트는 두 가지를 함께 본다. POST /profiles 요청에 대해 201 Created를 반환하는가 컨트롤러가 요청을 실제로 서비스에 위임하는가 잘못된 enum 값에 대한 400도 컨트롤러 테스트에서 검증했다. @Test @DisplayName("잘못된 region 값이면 400을 반환한다.") void createProfile_invalidRegion_returnsBadRequest() throws Exception { // given String requestBody = """ { "email": "myEmail@email.com", "nickname": "myNickname", "passwordHash": "HashedPassword", "profileImageUrl": "myProfileImageUrl", "region": "INVALID", "grade": "D", "ageGroup": "TWENTIES", "gender": "MALE" } """; // when & then mockMvc.perform(post("/profiles") .contentType(MediaType.APPLICATION_JSON) .content(requestBody)) .andExpect(status().isBadRequest()); } 또한 서비스 계층에서 던진 예외를 HTTP 상태 코드로 바꾸기 위해 전역 예외 처리기도 추가했다. @RestControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(IllegalArgumentException.class) public ResponseEntity<Void> handleIllegalArgumentException( IllegalArgumentException e ) { return ResponseEntity.status(HttpStatus.CONFLICT).build(); } } 이를 기반으로 중복 이메일과 같은 예외가 409 Conflict로 변환되는지도 테스트할 수 있었다. @Test @DisplayName("중복 이메일이면 409를 반환한다.") void createProfile_duplicateEmail_returnsConflict() throws Exception { // given String requestBody = """ { "email": "myEmail@email.com", "nickname": "myNickname", "passwordHash": "HashedPassword", "profileImageUrl": "myProfileImageUrl", "region": "SEOUL", "grade": "D", "ageGroup": "TWENTIES", "gender": "MALE" } """; given(profileService.create(any(CreateProfileRequest.class))) .willThrow(new IllegalArgumentException("이미 사용 중인 이메일입니다.")); // when & then mockMvc.perform(post("/profiles") .contentType(MediaType.APPLICATION_JSON) .content(requestBody)) .andExpect(status().isConflict()); } 즉, 컨트롤러 테스트는 도메인 규칙 테스트가 아니라 “HTTP 경계 테스트”로 가져가는 것이 중요했다. 리포지토리 테스트 리포지토리 테스트는 ProfileRepositoryTest를 기준으로 작성했다. 여기서는 mock을 사용하지 않고, 실제 JPA와 H2 DB를 붙여 query method와 영속성 동작을 검증했다. 사용한 구성은 다음과 같다. @DataJpaTest JPA 관련 빈만 로드하는 테스트 슬라이스 H2 in-memory DB 테스트용 데이터베이스 실제 ProfileRepository existsByEmail, existsByTag, saveAndFlush 검증 리포지토리 테스트의 목적은 서비스 규칙을 다시 검증하는 것이 아니다. 대신 “이 매핑과 query method를 실제 DB에서 믿을 수 있는가”를 확인하는 것이다. 예를 들어 existsByEmail()는 다음과 같이 테스트했다. @DataJpaTest class ProfileRepositoryTest { @Autowired private ProfileRepository profileRepository; @Test @DisplayName("존재하는 이메일이면 existsByEmail은 true를 반환한다.") void existsByEmail_existingEmail_returnsTrue() { // given Profile profile = createProfile("myEmail@email.com", "AB12"); profileRepository.saveAndFlush(profile); // when boolean result = profileRepository.existsByEmail("myEmail@email.com"); // then assertTrue(result); } @Test @DisplayName("존재하지 않는 이메일이면 existsByEmail은 false를 반환한다.") void existsByEmail_nonExistingEmail_returnsFalse() { // when boolean result = profileRepository.existsByEmail("nonExistedEmail@email.com"); // then assertFalse(result); } } 같은 방식으로 existsByTag()도 검증했다. @Test @DisplayName("존재하는 태그이면 existsByTag는 true를 반환한다.") void existsByTag_existingTag_returnsTrue() { // given Profile profile = createProfile("myEmail@email.com", "AB12"); profileRepository.saveAndFlush(profile); // when boolean result = profileRepository.existsByTag("AB12"); // then assertTrue(result); } 또한 saveAndFlush() 이후 id, createdAt, updatedAt이 실제로 채워지는지도 확인했다. @Test @DisplayName("프로필 저장 시 id, createdAt, updatedAt이 저장된다.") void save_profile_persistsAuditFields() { // given Profile profile = createProfile("email3@test.com", "EF56"); // when Profile savedProfile = profileRepository.saveAndFlush(profile); // then assertNotNull(savedProfile.getId()); assertNotNull(savedProfile.getCreatedAt()); assertNotNull(savedProfile.getUpdatedAt()); } 즉, 리포지토리 테스트는 mock 기반의 서비스 테스트로는 확인할 수 없는 JPA 매핑, 쿼리 메서드, 라이프사이클 콜백을 실제 DB와 함께 검증하는 역할을 한다. 마치며,,, 테스트 코드에 대해 오늘 다루어봤다. 테스트를 작성하면서 느낀 것은 clean code that works에서 중요한 것은 단순히 동작 여부만이 아니라, 변경을 더 안전하게 만들 수 있는 상태라는 점이었다. 테스트를 기능 요구사항과 실제 코드를 거의 1대1로 매칭할 수 있게 한다고 느꼈다. 그러나, 여기서 거의 1대1이라고 하는 이유는 테스트가 모든 것을 해결해주지는 않기 때문이다. 예시로 tag에 관련된 테스트를 작성하기 전에 다음과 같이 작성했었다. @Test @DisplayName("유효한 입력이면 프로필 생성에 성공한다") void createProfile_validInput_success() { // given String validEmail = "myEmail@email.com"; CreateProfileRequest request = createRequest(validEmail); given(profileRepository.save(any(Profile.class))) .willAnswer(invocation -> invocation.getArgument(0)); // when Profile profile = profileService.create(request); // then assertEquals(validEmail, profile.getEmail()); assertEquals("myNickname", profile.getNickname()); assertEquals("HashedPassword", profile.getPasswordHash()); assertEquals("myProfileImageUrl", profile.getProfileImageUrl()); assertEquals(Region.SEOUL, profile.getRegion()); assertEquals(Grade.D, profile.getGrade()); assertEquals(AgeGroup.TWENTIES, profile.getAgeGroup()); assertEquals(Gender.MALE, profile.getGender()); then(profileRepository).should().save(any(Profile.class)); } 이 테스트는 tag 기능 도입 전에 작성된 테스트인데, tag가 서버 생성 값으로 바뀌면서 테스트 계약도 함께 변경되어야 했다. 그런데 기존 테스트는 여전히 통과할 수 있었다. 즉, 테스트가 초록불이라고 해서 항상 충분한 테스트가 되었다는 뜻은 아니었다. 이러한 경우 기존 테스트를 수정하거나, 새로운 테스트를 추가해야한다. 이런 경우에 새롭게 어떻게 처리해야하는지 한 번 생각해보고 다음번에 공유해보고자 한다. 그리고 서론에서 AI 코딩에 대해서 언급했는데, 요즘 여러 AI코딩 덕분에 개발을 하는데 즐거움을 찾기 어려웠었다. 직접 코드를 작성하는 시간도 줄어들면서, 실제 문제를 혼자서 고민하는 시간도 줄어드는 것 같았다. 그래서 내가 공부한 내용을 데모 프로젝트로 직접 작성하는 것을 시작했는데, 이 덕분에 매우 코딩을 하는게 재미있었고 실제로 혼자 고민하는 시간도 늘어가면서 실력도 쌓이고 있는 것 같다. 그리고 조금 여러가지를 다루고 싶은 마음에 지금 TDD, 인프라, 아키텍처 등을 동시에 다루고 있는데, 이렇게 하는게 재미있어서 여러가지를 많이 다루고 싶다보니 그렇게 되었다. 그래서 이제는 조금 연속성을 가지고 블로그를 작성해보려고 한다. 참고 문서 Dan North, Introducing BDD Cucumber Docs, Behaviour-Driven Development Cucumber Docs, Gherkin Reference JUnit User Guide https://docs.junit.org/current/user-guide/ Mockito 공식 사이트 https://site.mockito.org/ Mockito BDDMockito Javadoc https://javadoc.io/doc/org.mockito/mockito-core/latest/org.mockito/org/mockito/BDDMockito.html Martin Fowler, Test Double Spring Boot Testing 문서 https://docs.spring.io/spring-boot/reference/testing/spring-boot-applications.html Spring Framework MockMvc 문서 https://docs.spring.io/spring-framework/reference/testing/mockmvc.html Spring Framework Test Bean Override 문서 (@MockitoBean) https://docs.spring.io/spring-framework/reference/testing/testcontext-framework/bean-overriding.html 카카오 TDD 블로그

2026년 3월 10일

[Docker] EC2에서 Docker Compose와 GitHub Actions로 배포 자동화하기

서론 지난 포스팅에서는 Docker가 무엇인지, 왜 등장했는지, 그리고 기존 가상화 방식과 비교했을 때 어떤 장점이 있는지에 대해 정리해보았다. 그리고 내 서비스를 배포하고, 운영을 고려한 개발을 수행하려다보니 Docker로 서비스의 실행 환경을 일관되게 유지하면서 개발하는 것이 왜 중요한지 알 수 있었다. 따라서 Docker를 공부한 뒤에 실제 내 개인 프로젝트에서 어떻게 Docker 설정부터 CI/CD까지 어떻게 운영했는지에 대해 간단히 다뤄보고자 한다. 이번 포스팅의 내용은 개념을 다룬다기 보다는 implementation 형식의 포스팅이 되겠다. 1. 현재 배포 구조 현재 프로젝트 Organization 링크는 다음과 같다. RallyOn 현재 사이드 프로젝트는 크게 다음 3가지 영역으로 구성되어 있다. backend: Spring Boot 기반 백엔드 애플리케이션 frontend: Next.js 기반 프론트엔드 애플리케이션 infra: 로컬 개발 환경 구성을 위한 인프라 설정 디렉토리 구조는 다음과 같다. rally-on/ ├── backend/ # Spring Boot ├── frontend/ # Next.js └── infra/ # Local Development 운영 환경은 backend 디렉토리를 중심으로 Docker 이미지 빌드와 GitHub Actions 기반 CI/CD가 구성되어 있다. 실제 운영 배포는 단일 AWS EC2 서버에서 이루어지며, 백엔드 애플리케이션과 PostgreSQL 데이터베이스 컨테이너가 함께 실행된다. 즉, 현재 EC2는 애플리케이션을 직접 빌드하는 서버라기보다, 검증이 끝난 이미지를 받아 실행하는 운영 서버 역할을 한다. infra는 로컬 개발 환경으로 실제 HTTPS 요청 등을 로컬에서 직접 실행해보고 운영 환경에 올리기 위해 설정한 repo이다. 2. Push 이후 배포는 어떻게 이루어질까 가장 먼저 개발이 이루어진 뒤에 코드를 Github에 Push•PR•Merge가 이루어진다. 여기서 Main에 Merge 즉, Push가 된 뒤에 어떤 일이 발생하는지 공유한다. 현재 운영 배포는 GitHub Actions를 중심으로 자동화되어 있다. 즉, 코드가 저장소에 반영되면 GitHub Actions가 이를 감지하여 빌드와 배포를 순차적으로 수행하는 구조이다. 따라서 현재 배포 흐름은 다음과 같다. 이를 단계별로 정리하면 다음과 같다. 개발자가 backend 저장소의 main 브랜치에 코드를 push 한다. GitHub Actions의 CI(Continuous Integration) workflow가 실행된다. CI 단계에서 테스트와 애플리케이션 빌드가 수행된다. 이후 Docker 이미지를 빌드하고, GHCR(GitHub Container Registry)에 push 한다. CI가 성공적으로 완료되면 CD(Continuous Deployment) workflow가 실행된다. CD는 AWS EC2 서버에 SSH로 접속한다. EC2 서버는 GHCR에 저장된 최신 이미지를 pull 한다. 마지막으로 docker compose up -d 명령을 실행하여 컨테이너를 다시 구성한다. 이 과정에서 Docker는 애플리케이션을 이미지 단위로 패키징하고, 운영 서버에서는 해당 이미지를 pull 받아 동일한 실행 환경으로 컨테이너를 다시 구성하는 역할을 한다. 즉, 현재 배포 구조는 크게 두 단계로 나눌 수 있다. CI: 애플리케이션 검증과 Docker 이미지 생성 및 push 담당 CD: 운영 서버에 최신 이미지를 반영하고 컨테이너를 재실행하는 역할 담당 다음으로는 이 과정을 실제로 담당하는 ci.yml과 cd.yml 파일을 보면서, CI와 CD가 각각 어떤 방식으로 구성되어 있는지 정리해보고자 한다. 3. CI는 어떻게 구성되어 있을까? 앞서 살펴본 것처럼 현재 배포는 CI와 CD 두 단계로 나뉜다. 그중 먼저 CI는 코드 변경이 발생했을 때 애플리케이션을 검증하고, 운영 배포에 사용할 Docker 이미지를 생성하는 역할을 한다. 현재 backend 저장소의 CI workflow 핵심 부분은 다음과 같다. name: CI - 이미지 빌드 및 푸시 on: # main 브랜치로 들어가는 PR이 열리면 검증 수행 pull_request: branches: [ "main" ] # dev, main 브랜치에 push 되었을 때도 CI 실행 push: branches: - dev - main permissions: contents: read packages: write jobs: build: # GitHub Actions가 제공하는 Ubuntu 환경에서 실행 runs-on: ubuntu-latest steps: # 저장소 코드를 runner 환경으로 가져옴 - name: 코드 체크아웃 uses: actions/checkout@v4 # Java 21 실행 환경 설정 - name: Java 21 설정 uses: actions/setup-java@v4 with: java-version: '21' distribution: 'temurin' # Gradle 테스트와 bootJar 빌드 수행 # 애플리케이션이 정상적으로 빌드 가능한지 함께 검증 - name: Gradle 테스트 및 빌드 run: ./gradlew clean test bootJar # main 브랜치에 push 된 경우에만 GHCR 로그인 수행 # 즉, 모든 CI가 이미지를 push하는 것은 아님 - name: GHCR 로그인 if: github.event_name == 'push' && github.ref == 'refs/heads/main' uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} # main 브랜치 push인 경우에만 Docker 이미지 빌드 및 push 수행 # 운영 배포에 사용할 이미지를 GHCR에 업로드 - name: Docker 이미지 빌드 및 푸시 if: github.event_name == 'push' && github.ref == 'refs/heads/main' run: | IMAGE=ghcr.io/drivebadminton/drive-backend:latest docker build -t $IMAGE . docker push $IMAGE 위 workflow를 보면 CI는 크게 두 가지 역할을 담당한다. 애플리케이션 검증: PR 또는 push 시점에 테스트와 빌드를 수행하여 코드가 정상적으로 동작하는지 확인 배포용 이미지 생성: main 브랜치에 push 된 경우에만 Docker 이미지를 빌드하고 GHCR에 push 즉, CI는 단순히 테스트만 수행하는 단계가 아니라, 실제 운영 서버에서 사용할 배포 결과물까지 만들어내는 단계라고 볼 수 있다. 3.1 이미지는 어떻게 만들어지고 실행될까? 그렇다면 실제 배포용 이미지는 어떤 과정을 거쳐 만들어질까? 여기서 먼저 역할을 구분해보면 다음과 같다. Dockerfile: 애플리케이션을 Docker 이미지로 만든다. docker-compose.yml: 생성된 이미지를 어떤 방식으로 실행할지 정의한다. 즉, 현재 구조에서 이미지를 생성하는 핵심은 Dockerfile이고, docker-compose.yml은 생성된 이미지를 운영 환경에서 실행하는 역할에 가깝다. 먼저 Dockerfile은 백엔드 애플리케이션을 실행 가능한 Docker 이미지로 만드는 역할을 한다. # 애플리케이션을 빌드하기 위한 이미지 FROM eclipse-temurin:21-jdk AS builder # 컨테이너 내부 작업 디렉토리를 /workspace로 지정 WORKDIR /workspace # Gradle 실행과 빌드에 필요한 파일 복사 COPY gradlew . # Gradle Wrapper 실행 파일 COPY gradle gradle # Gradle 설정 디렉토리 COPY build.gradle settings.gradle ./ # 빌드 설정 파일 COPY src src # 실제 애플리케이션 소스 코드 # Spring Boot 실행 가능한 JAR 파일 생성 # 결과물은 build/libs 아래에 생성된다. RUN ./gradlew --no-daemon bootJar # 실제 실행만 담당하는 가벼운 이미지 # 빌드 도구가 포함된 JDK 대신, 실행에 필요한 JRE만 사용한다. FROM eclipse-temurin:21-jre # 시간대를 한국으로 설정 ENV TZ=Asia/Seoul # 애플리케이션 실행 디렉토리 지정 WORKDIR /app # 앞 단계에서 생성한 JAR 파일만 최종 이미지에 복사 # 즉, 소스코드 전체가 아니라 빌드 결과물만 포함된다. COPY --from=builder /workspace/build/libs/*.jar app.jar # 애플리케이션 포트 번호 명시 EXPOSE 8080 # 컨테이너 시작 시 Spring Boot 애플리케이션 실행 ENTRYPOINT ["java", "-jar", "/app/app.jar"] 이 Dockerfile의 핵심은 멀티 스테이지 빌드(Multi-stage Build) 를 사용하고 있다는 점이다. 앞 단계에서는 JDK가 포함된 이미지에서 Gradle 빌드를 수행하고, 뒤 단계에서는 실제 실행에 필요한 JAR 파일만 복사해 더 가벼운 JRE 이미지에서 애플리케이션을 실행한다. 즉, 최종 이미지에는 컴파일 도구나 소스코드 전체가 포함되지 않고, 실행에 필요한 결과물만 남게 된다. JDK(Java Development Kit)는 자바 프로그램을 개발하고 빌드하기 위한 환경이다. JRE(Java Runtime Environment)는 이미 만들어진 자바 프로그램을 실행하기 위한 환경이다. 따라서 이 Dockerfile에서는 빌드 단계에서는 JDK를 사용하고, 실행 단계에서는 더 가벼운 JRE만 사용해 최종 이미지를 단순하게 구성하고 있다. 이후 운영 서버에서는 docker-compose.yml을 통해 이 이미지를 실제로 실행한다. 운영 환경에서 사용하는 Compose 설정의 핵심은 다음과 같다. services: db: image: postgres:16 app: image: ghcr.io/drivebadminton/drive-backend:latest depends_on: db: condition: service_healthy 여기서 중요한 점은 app 서비스가 build:가 아니라 image:를 사용한다는 것이다. 즉, 운영 서버는 소스 코드로부터 이미지를 새로 빌드하지 않는다. 대신 CI 단계에서 이미 만들어져 GHCR에 push 된 이미지를 pull 받아 실행한다. 정리하면 현재 배포 흐름은 다음과 같이 이어진다. CI에서 bootJar를 통해 Spring Boot 애플리케이션을 빌드한다. Dockerfile이 이 결과물을 포함한 Docker 이미지를 만든다. 생성된 이미지를 GHCR에 push 한다. EC2 서버는 docker compose pull로 최신 이미지를 내려받는다. 마지막으로 docker compose up -d로 컨테이너를 다시 실행한다. 결국 Dockerfile은 애플리케이션을 어떤 방식으로 이미지화할 것인가를 정의하고, docker-compose.yml은 그 이미지를 운영 환경에서 어떻게 실행할 것인가를 정의한다고 볼 수 있다. 4. CD는 어떻게 구성되어 있을까? 앞서 살펴본 CI가 애플리케이션을 검증하고 배포용 이미지를 만드는 역할을 한다면, CD는 그 이미지를 실제 운영 서버에 반영하는 역할을 담당한다. 현재 backend 저장소의 cd.yml은 다음과 같이 구성되어 있다. name: CD - EC2 배포 on: # CI workflow가 끝났을 때 CD가 실행 대상이 됨 workflow_run: workflows: ["CI - 이미지 빌드 및 푸시"] types: - completed # 필요하면 GitHub Actions 화면에서 수동 실행도 가능함 workflow_dispatch: jobs: deploy: # 수동 실행이거나, # workflow_run으로 실행된 경우에는 CI가 성공했을 때만 배포 if: > github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' # GitHub Actions가 제공하는 Ubuntu 러너에서 작업 수행 runs-on: ubuntu-latest steps: - name: EC2 배포 # SSH로 원격 서버에 접속해 배포 스크립트를 실행하는 액션 uses: appleboy/ssh-action@v1.0.3 env: # GHCR 로그인에 사용되는 계정 정보 GHCR_USERNAME: ${{ github.actor }} GHCR_PASSWORD: ${{ secrets.GITHUB_TOKEN }} with: # GitHub Secrets에 저장된 EC2 접속 정보 사용 host: ${{ secrets.EC2_HOST }} username: ubuntu key: ${{ secrets.EC2_SSH_KEY }} # 위에서 선언한 환경 변수를 원격 서버에도 전달 envs: GHCR_USERNAME,GHCR_PASSWORD script: | # 서버에 배포용 프로젝트 디렉토리로 이동 cd ~/Drive-Backend # GHCR 로그인해서 최신 이미지를 pull 받을 수 있도록 설정 echo "$GHCR_PASSWORD" | docker login ghcr.io -u "$GHCR_USERNAME" --password-stdin # docker-compose.yml에 정의된 최신 이미지 다운로드 docker compose pull # 최신 이미지 기준으로 컨테이너 재생성 및 백그라운드 실행 # 사용하지 않는 orphan 컨테이너는 함께 정리 docker compose up -d --remove-orphans # 배포 후 불필요해진 이미지 정리 docker image prune -af CD workflow는 CI가 성공적으로 끝난 경우 자동으로 이어서 실행되며, 필요하면 수동으로도 실행할 수 있다. 즉, 테스트와 빌드가 통과하고 Docker 이미지가 정상적으로 GHCR에 올라간 이후에만 자동 운영 배포가 진행된다. 실제 배포 단계는 비교적 단순하다. GitHub Actions가 EC2 서버에 SSH로 접속한다. 서버에서 GHCR 로그인을 수행한다. docker compose pull로 최신 이미지를 내려받는다. docker compose up -d --remove-orphans로 컨테이너를 재구성한다. 마지막으로 사용하지 않는 이미지를 정리한다. 여기서 중요한 점은 서버에서 직접 애플리케이션을 빌드하지 않고, 검증된 이미지를 가져와 실행만 한다는 것이다. 이는 앞서 살펴본 것처럼, CI 단계에서 이미 bootJar를 통해 JAR 파일이 생성되고, Dockerfile을 통해 실행 가능한 Docker 이미지까지 만들어져 GHCR에 저장되기 때문이다. 즉, CD 단계의 EC2 서버는 소스 코드를 받아 다시 빌드하는 것이 아니라, 이미 검증이 끝난 이미지를 pull 받아 컨테이너를 재실행하는 역할만 수행한다. 이 방식은 운영 서버의 책임을 단순하게 만들고, 배포 시점마다 빌드 환경 차이로 인해 발생할 수 있는 문제를 줄여준다는 장점이 있다. 5. 현재 구조의 장점 현재 배포 구조는 아주 거창한 형태는 아니지만, 사이드 프로젝트를 운영하는 입장에서는 분명한 장점이 있다. 첫 번째는 구성이 단순하다는 점이다. 운영 환경은 단일 EC2 서버와 Docker Compose를 기반으로 구성되어 있기 때문에 전체 흐름을 파악하기 어렵지 않다. 어떤 방식으로 이미지를 만들고, 어디에 저장하고, 서버에서 어떻게 다시 실행하는지 비교적 명확하게 이해할 수 있다. 두 번째는 배포 과정이 일관되다는 점이다. CI에서 테스트와 빌드를 통과한 뒤 이미지를 만들고, CD에서는 그 이미지를 pull 받아 실행한다. 즉, 운영 서버는 직접 소스 코드를 빌드하지 않고 검증된 결과물만 받아 실행하게 된다. 세 번째는 배포 자동화를 어느 정도 확보했다는 점이다. 이전처럼 서버에 직접 접속해 수동으로 빌드하고 재실행하는 것이 아니라, main 브랜치 기준으로 GitHub Actions가 빌드와 배포를 순차적으로 수행한다. 작은 프로젝트라도 이런 자동화가 갖춰지면 반복 작업과 배포 실수를 줄이는 데 도움이 된다. 네 번째는 Docker 중심으로 개발 환경과 운영 환경을 맞추려 했다는 점이다. infra 저장소를 통해 로컬에서도 Docker Compose 기반으로 서비스를 구성하면서, 개발 환경과 운영 환경의 차이를 최대한 줄이려는 방향을 가져갈 수 있었다. 정리하면 현재 구조는 작은 규모의 프로젝트를 빠르게 운영하고, Docker와 CI/CD를 실제로 적용해보기에는 충분히 실용적인 구조라고 생각한다. 6. 현재 구조의 한계와 개선 포인트 물론 현재 구조가 항상 좋은 선택인 것은 아니다. 사이드 프로젝트를 빠르게 운영하기에는 충분했지만, 운영 관점에서 보면 몇 가지 한계도 분명히 존재한다. 첫 번째는 모든 것이 하나의 서버에 묶여 있다는 점이다. 현재는 EC2 한 대에서 애플리케이션과 PostgreSQL 컨테이너가 함께 실행되고 있다. 구성이 단순하다는 장점은 있지만, 반대로 보면 서버 하나에 문제가 생겼을 때 전체 서비스가 함께 영향을 받는 구조이기도 하다. 두 번째는 확장성이 제한적이라는 점이다. Docker Compose 환경에서는 컨테이너를 실행하고 재시작하는 것은 편하지만, 트래픽 증가에 따라 애플리케이션 인스턴스를 유연하게 늘리거나 줄이는 데에는 한계가 있다. 여러 개의 애플리케이션 인스턴스를 안정적으로 운영하고, 그 앞단에서 트래픽을 분산하는 구조를 가져가려면 더 많은 고민이 필요하다. 세 번째는 배포 전략이 비교적 단순하다는 점이다. 현재 방식은 최신 이미지를 pull 받고 docker compose up -d로 컨테이너를 다시 띄우는 구조다. 이 방식은 구현이 단순하다는 장점이 있지만, 롤링 업데이트나 점진적 배포처럼 더 세밀한 배포 전략을 적용하기는 어렵다. 네 번째는 이미지 버전 관리 측면의 아쉬움이다. 현재는 latest 태그를 기준으로 이미지를 운영하고 있는데, 이 방식은 단순하지만 “지금 서버에 올라간 이미지가 정확히 어떤 버전인지”를 추적하기 어렵게 만들 수 있다. 운영 안정성을 높이려면 커밋 SHA나 버전 태그를 함께 사용하는 방식도 고려할 수 있다. 다섯 번째는 운영 관리 요소가 아직 부족하다는 점이다. 현재 구조만으로도 배포는 가능하지만, 서비스가 커질수록 로그 수집, 모니터링, 장애 대응, 비밀 값 관리 같은 운영 요소가 점점 중요해진다. 즉, “컨테이너를 실행할 수 있다”는 것과 “운영 가능한 형태로 관리할 수 있다”는 것은 조금 다른 문제였다. 이런 한계들은 지금 당장 반드시 해결해야 하는 문제라기보다, 프로젝트가 점점 커질수록 자연스럽게 마주치게 되는 운영상의 고민에 가깝다. 그리고 바로 이 지점에서, 단순히 컨테이너를 실행하는 도구를 넘어서 컨테이너를 보다 체계적으로 배포하고 관리할 수 있는 플랫폼에 관심을 가지게 되었다. 마치며 이번 글에서는 현재 사이드 프로젝트에 적용되어 있는 Docker 기반 배포 구조와, GitHub Actions를 활용한 CI/CD 흐름을 기준으로 실제 배포가 어떤 식으로 이루어지고 있는지 정리해보았다. 정리해보면 지금의 구조는 다음과 같다. CI에서 테스트와 빌드를 수행한다. Docker 이미지를 생성해 GHCR에 push 한다. CD에서 EC2 서버가 해당 이미지를 pull 받아 컨테이너를 다시 실행한다. 이 구조는 개인 프로젝트를 운영하는 입장에서는 충분히 단순하고 실용적이었다. 특히 Docker를 단순히 이론으로 이해하는 것이 아니라, 실제로 빌드 결과물을 이미지로 만들고 운영 서버에 반영하는 흐름까지 경험할 수 있었다는 점에서 의미가 있었다. 하지만 동시에, 서비스가 커지거나 운영 요구사항이 복잡해질수록 현재 방식만으로는 한계가 있다는 점도 함께 느낄 수 있었다. 그래서 다음 글에서는 이러한 한계를 바탕으로, 현재 Docker Compose 중심의 배포 구조를 Kubernetes 관점에서 어떻게 확장해볼 수 있을지 정리해보려고 한다. 참고문헌 이전 글: Docker, 도커란 무엇인가 RallyOn Organization: https://github.com/RallyOnPrj Docker 공식 문서: https://docs.docker.com/ Docker Compose 공식 문서: https://docs.docker.com/compose/ GitHub Actions 공식 문서: https://docs.github.com/actions GitHub Container Registry 문서: https://docs.github.com/packages/working-with-a-github-packages-registry/working-with-the-container-registry Kubernetes 공식 문서: https://kubernetes.io/docs/

2026년 2월 25일
docker

[Docker] 도커란 무엇인가..

백엔드 개발자라면 배포와 운영까지 염두에 둔 실행 환경을 설계해야 한다. 그리고 그 출발점은, 어쩌면 로컬에서부터 동일한 환경을 재현할 수 있게 해주는 Docker가 아닐까?