Skip to main content
Hea02y
developer @vestellalab
View all authors

GCP 입문부터 인프라 구축까지

· 9 min read

그동안 NCPAWS를 사용해왔는데, 이번에 GCP를 사용할 일이 생겨서 기초부터 정리하고 실제 인프라를 구축해보기로 했다. GCP의 계층 구조부터 VPC, VM, Cloud SQL까지 구축하면서 다른 클라우드와 다르다고 느꼈던 부분들을 기록해보자.

GCP 계층 구조

GCP는 리소스를 관리하는 계층 구조가 좀 독특하다. AWS와 비교하면 확실히 다른 느낌이다.

Organization
└─ Folder
└─ Project
└─ Resource

Organization

  • 조직의 최상위 단위. 하나의 도메인을 기반으로 생성
  • 도메인 소유권을 증명하면 GCP Console에 조직이 생성된다
  • IAM, Billing, 정책 관리의 기준점이 된다

Folder

  • 부서, 서비스, 환경별로 구분하여 프로젝트를 정리할수있는 중간 단위
  • 계층적 구조라서 상속 기반으로 IAM/정책 적용이 가능하다

Project

  • 리소스 생성과 운영의 기본 단위. VM, Storage, GKE 등이 프로젝트 하위에 존재한다
  • 결제도 이 단위에서 연결된다

Resource

  • 실질적인 사용 자원 (VM, Cloud SQL, Storage 등)
  • 반드시 하나의 프로젝트에 소속된다

AWS에서는 계정 단위로 리소스를 관리하는 느낌이라면, GCP는 Organization > Folder > Project 구조로 좀더 명확하게 계층이 나뉘어 있다. 팀이 크거나 환경(dev/staging/prod)이 여러개인 경우 이 구조가 꽤 유용하다.

IAM 기본 개념

GCP의 IAM은 누가(Who) 무엇을(What) 어디에(Where) 접근할수있는지를 정의한다.

Member + Role → Resource
  • Member: Google 계정, 서비스 계정, 그룹 등
  • Role: 권한의 묶음 (Viewer, Editor, Owner 또는 커스텀 역할)
  • Resource: 역할이 적용되는 대상 (프로젝트, 폴더, 개별 리소스)

주의할 점은 Role이 상위 계층에서 하위로 상속된다는 것이다. Organization에 Editor를 부여하면 하위 모든 프로젝트에 적용되니까, 최소 권한 원칙을 지키려면 가능한 낮은 계층에서 역할을 부여하자.

서비스 계정

실제 인프라를 구축하면 애플리케이션이 GCP API에 접근해야하는 상황이 생긴다. 이때 사용하는게 Service Account다.

# 서비스 계정 생성
gcloud iam service-accounts create my-app-sa \
--display-name="My App Service Account"

# 역할 부여
gcloud projects add-iam-policy-binding my-project \
--member="serviceAccount:my-app-sa@my-project.iam.gserviceaccount.com" \
--role="roles/cloudsql.client"

# 키 파일 생성
gcloud iam service-accounts keys create key.json \
--iam-account=my-app-sa@my-project.iam.gserviceaccount.com

하지만 키 파일은 유출되면 큰 문제가 생기니까 가능하면 Workload Identity를 사용하는게 좋다.

VPC 구축

GCP의 VPC는 AWS와 다르게 글로벌 리소스다. 하나의 VPC가 여러 리전에 걸쳐서 존재할수있다.

# VPC 생성
gcloud compute networks create my-vpc \
--subnet-mode=custom

# 서브넷 생성 (서울 리전)
gcloud compute networks subnets create my-subnet \
--network=my-vpc \
--region=asia-northeast3 \
--range=10.0.1.0/24

AWS에서는 VPC가 리전에 종속되어있어서, 다른 리전끼리 통신하려면 VPC Peering이 필요하다. 하지만 GCP는 같은 VPC 안에서 리전이 달라도 내부 통신이 가능하다. 이게 꽤 편하다.

방화벽 규칙

GCP에서는 Security Group 대신 Firewall Rules를 사용한다. VPC 단위로 적용되고, 태그 기반으로 대상을 지정할수있다.

# SSH 허용
gcloud compute firewall-rules create allow-ssh \
--network=my-vpc \
--allow=tcp:22 \
--source-ranges=0.0.0.0/0 \
--target-tags=allow-ssh

# 내부 통신 허용
gcloud compute firewall-rules create allow-internal \
--network=my-vpc \
--allow=tcp,udp,icmp \
--source-ranges=10.0.0.0/16

target-tags로 특정 태그가 붙은 VM에만 규칙을 적용할수있다. AWS의 Security Group과 비슷하지만, 네트워크 레벨에서 동작한다는 차이가 있다.

Compute Engine (VM) 생성

gcloud compute instances create my-server \
--zone=asia-northeast3-a \
--machine-type=e2-medium \
--subnet=my-subnet \
--image-family=ubuntu-2204-lts \
--image-project=ubuntu-os-cloud \
--boot-disk-size=50GB \
--tags=allow-ssh

접속은 gcloud compute ssh로 간단하게 가능하다.

gcloud compute ssh my-server --zone=asia-northeast3-a

AWS에서는 키페어 관리가 번거로운데, GCP는 gcloud ssh가 자동으로 키를 관리해줘서 편하다. 하지만 프로덕션 환경에서는 OS Login을 설정해서 IAM 기반으로 SSH 접근을 관리하는게 좋다.

Cloud SQL 구축

Cloud SQL은 GCP의 관리형 RDB 서비스다. MySQL, PostgreSQL, SQL Server를 지원한다.

# Cloud SQL 인스턴스 생성 (MySQL)
gcloud sql instances create my-db \
--database-version=MYSQL_8_0 \
--tier=db-custom-2-4096 \
--region=asia-northeast3 \
--network=my-vpc \
--no-assign-ip

--no-assign-ip를 주면 퍼블릭 IP 없이 내부 IP만 할당된다. 보안상 이렇게 설정하는게 맞다.

# 데이터베이스 생성
gcloud sql databases create mydb --instance=my-db

# 사용자 생성
gcloud sql users create admin \
--instance=my-db \
--password=비밀번호

Private IP 접근 설정

Cloud SQL에 Private IP로 접근하려면 Private Service Access를 설정해야한다.

# Private Service Access 설정
gcloud compute addresses create google-managed-services \
--global \
--purpose=VPC_PEERING \
--prefix-length=16 \
--network=my-vpc

gcloud services vpc-peerings connect \
--service=servicenetworking.googleapis.com \
--ranges=google-managed-services \
--network=my-vpc

이 설정을 하면 같은 VPC 내의 VM에서 Cloud SQL의 Private IP로 직접 접근이 가능해진다.

Spring Boot 연동

spring:
datasource:
url: jdbc:mysql://10.x.x.x:3306/mydb
username: admin
password: ${DB_PASSWORD}
driver-class-name: com.mysql.cj.jdbc.Driver

VM에서 Cloud SQL로의 접근이 정상적으로 되는지 먼저 확인하자.

mysql -h 10.x.x.x -u admin -p

AWS vs GCP 느낀점

실제로 구축해보면서 느낀 차이점을 정리해보자.

항목AWSGCP
VPC리전 종속글로벌
방화벽Security Group (인스턴스 단위)Firewall Rules (네트워크 단위, 태그 기반)
SSH키페어 직접 관리gcloud ssh 자동 관리
CLIaws-cligcloud (좀더 직관적)
콘솔 UI복잡하지만 기능 많음깔끔하지만 가끔 메뉴 찾기 어려움

개인적으로 gcloud CLI가 AWS CLI보다 직관적이라고 느꼈다. gcloud compute instances create 같은 명령어가 뭘 하는건지 바로 읽힌다. 하지만 한국 리전 기준으로 서비스 종류나 레퍼런스는 AWS가 아직 압도적으로 많다.

마무리

NCP, AWS를 거쳐서 GCP까지 써보니 클라우드마다 확실히 철학이 다르다는걸 느꼈다. GCP는 글로벌 VPC나 IAM 상속 구조처럼 큰 조직에서 관리하기 좋은 방향으로 설계된 느낌이다. 하지만 실무에서는 결국 팀이 익숙한 클라우드를 쓰는게 가장 효율적이고, 중요한건 어떤 클라우드를 쓰든 네트워크와 보안 기본기를 탄탄히 갖추는것이라고 생각한다.

MongoDB 연동을 위한 튜토리얼

· 2 min read
//Config

@Configuration
@EnableMongoAuditing
class MongoConfig {
}
//Repository

interface MailContentsRepository : MongoRepository<MailContents, String> {
}
//Entity(Document)
@Document(collection = "mail_contents")
class MailContents(

@Id
val id : String? = null,

var name : String,

var content : String,

var category: MailCategory = MailCategory.NONE,

var siteLink: String,

@CreatedDate
val createdAt: Instant? = null,

@LastModifiedDate
val updatedAt: Instant? = null,
)

yaml 에서 설정을 할때 2가지 방식으로 가능하다. 나는 첫번째 방식인 URI를 통해서 연동을 진행했다.

spring:  
data:
mongodb:
uri: mongodb+srv://{id}:{password}@{uri}/{dbName}?retryWrites=true&w=majority
spring:
data:
mongdb:
host:
port:
user:
password:

TestCode를 통해 DB에 insert를 진행하니 정상적으로 데이터가 저장된다.

@Test  
@DisplayName("Insert 테스트")
fun insert() {
val mailContents =
MailContents(null, "name", "contents", MailCategory.NONE, "test.com", Instant.now(), Instant.now())
mailContentsRepository.save(mailContents)
}

작업결과

작업결과

확인

확인

이때 RDB는 서버 기동과 동시에 DDL을 통해 테이블과 스키마를 만들지만, MongoDB에서는 컬렉션을 미리 만들지 않고, 첫 insert가 진행되는 시점에서 자동 생성된다. 간단하게 정리해보자.

RDB

  • 테이블 먼저 생성
  • 그 다음에 INSERT 가능

MongoDB

  • 컬렉션을 미리 안 만들어도 됨
  • 첫 insert 시점에 자동 생성

Github로 OAuth2 빠르게 구현하기

· 6 min read

개인 프로젝트에 로그인 기능을 넣으려고 하다가, 직접 회원가입/로그인을 구현하는게 너무 번거로워서 OAuth2를 도입하기로 했다. 그중에서도 개발자라면 누구나 가지고 있는 GitHub 계정을 활용한 소셜 로그인을 구현해보자.

OAuth2 간단 정리

OAuth2는 외부 서비스(GitHub, Google 등)에 사용자 인증을 위임하는 프로토콜이다. 직접 비밀번호를 관리할 필요가 없고, 사용자 입장에서도 클릭 몇 번으로 로그인이 가능해진다. 흐름을 간단하게 정리하면 다음과 같다.

  1. 사용자가 "GitHub로 로그인" 버튼 클릭
  2. GitHub 인증 페이지로 리다이렉트
  3. 사용자가 GitHub에서 권한 허용
  4. GitHub이 Authorization Code를 우리 서버로 전달
  5. 서버가 Authorization CodeAccess Token 요청
  6. Access Token으로 GitHub API에서 사용자 정보 조회
  7. 사용자 정보를 기반으로 회원가입/로그인 처리

GitHub OAuth App 등록

먼저 GitHub에서 OAuth App을 등록해야한다. GitHub Developer Settings에 접속해서 New OAuth App을 클릭하자.

Application name: 내 프로젝트 이름
Homepage URL: http://localhost:8080
Authorization callback URL: http://localhost:8080/login/oauth2/code/github

등록하면 Client IDClient Secret이 발급된다. 이 값들을 잘 저장해두자.

의존성 추가

build.gradle.kts에 다음 의존성을 추가한다.

dependencies {
implementation("org.springframework.boot:spring-boot-starter-oauth2-client")
implementation("org.springframework.boot:spring-boot-starter-security")
implementation("org.springframework.boot:spring-boot-starter-web")
}

spring-boot-starter-oauth2-client만 추가하면 Spring Security가 OAuth2 로그인 흐름을 거의 다 처리해준다. 이게 진짜 편하다.

application.yml 설정

spring:
security:
oauth2:
client:
registration:
github:
client-id: ${GITHUB_CLIENT_ID}
client-secret: ${GITHUB_CLIENT_SECRET}
scope: read:user, user:email

scoperead:useruser:email을 넣어줘야 사용자 프로필과 이메일 정보를 가져올수있다. 여기까지만 설정하면 사실 /login 으로 접근하면 GitHub 로그인 화면이 뜬다. 하지만 로그인 이후 사용자 정보를 저장하고 관리하려면 추가 작업이 필요하다.

Entity 구성

GitHub에서 가져온 사용자 정보를 저장할 Entity를 만들자.

@Entity
@Table(name = "users")
class User(

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null,

@Column(unique = true, nullable = false)
val githubId: Long,

var name: String,

var email: String?,

var avatarUrl: String?,

@Enumerated(EnumType.STRING)
var role: Role = Role.USER,
)

enum class Role {
USER, ADMIN
}
interface UserRepository : JpaRepository<User, Long> {
fun findByGithubId(githubId: Long): User?
}

OAuth2 로그인 처리

핵심은 OAuth2UserService를 커스텀하는 부분이다. GitHub에서 받아온 사용자 정보를 우리 DB에 저장하는 로직을 작성하자.

@Service
class CustomOAuth2UserService(
private val userRepository: UserRepository,
) : DefaultOAuth2UserService() {

override fun loadUser(userRequest: OAuth2UserRequest): OAuth2User {
val oAuth2User = super.loadUser(userRequest)
val attributes = oAuth2User.attributes

val githubId = (attributes["id"] as Int).toLong()
val name = attributes["login"] as String
val email = attributes["email"] as? String
val avatarUrl = attributes["avatar_url"] as? String

val user = userRepository.findByGithubId(githubId)
?: userRepository.save(
User(
githubId = githubId,
name = name,
email = email,
avatarUrl = avatarUrl,
)
)

// 기존 유저라면 정보 업데이트
user.name = name
user.email = email
user.avatarUrl = avatarUrl
userRepository.save(user)

return DefaultOAuth2User(
listOf(SimpleGrantedAuthority("ROLE_${user.role.name}")),
attributes,
"login" // GitHub의 nameAttributeKey
)
}
}

GitHub에서 넘어오는 attributes에는 id, login, email, avatar_url 등이 포함되어있다. 처음 로그인하는 사용자라면 save하고, 기존 사용자라면 정보를 업데이트 해준다.

Security 설정

@Configuration
@EnableWebSecurity
class SecurityConfig(
private val customOAuth2UserService: CustomOAuth2UserService,
) {

@Bean
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
http
.csrf { it.disable() }
.authorizeHttpRequests {
it
.requestMatchers("/", "/login/**", "/css/**", "/js/**").permitAll()
.anyRequest().authenticated()
}
.oauth2Login {
it.userInfoEndpoint { endpoint ->
endpoint.userService(customOAuth2UserService)
}
it.defaultSuccessUrl("/", true)
}
.logout {
it.logoutSuccessUrl("/")
}

return http.build()
}
}

oauth2Login에 우리가 만든 customOAuth2UserService를 등록해주면 된다. 로그인 성공시 메인 페이지로 리다이렉트 되도록 설정했다.

로그인 사용자 정보 가져오기

컨트롤러에서 로그인된 사용자 정보를 가져오는 방법은 다음과 같다.

@RestController
class UserController {

@GetMapping("/api/user")
fun getUser(@AuthenticationPrincipal oAuth2User: OAuth2User): Map<String, Any?> {
return mapOf(
"name" to oAuth2User.attributes["login"],
"avatar" to oAuth2User.attributes["avatar_url"],
"email" to oAuth2User.attributes["email"],
)
}
}

@AuthenticationPrincipal을 통해 현재 로그인한 사용자의 정보를 바로 꺼내올수있다.

마무리

Spring Security + OAuth2 Client를 사용하면 GitHub 로그인 구현이 정말 간단하다. 직접 해보면 application.yml 설정과 OAuth2UserService 하나만 구현하면 거의 끝난다. 회원가입 폼이나 비밀번호 관리 같은 귀찮은 작업을 전부 생략할수있다는게 가장 큰 장점이다. 하지만 GitHub OAuth는 개발자 대상 서비스에서 적합하고, 일반 사용자 대상이라면 Google이나 Kakao를 추가하는게 좋겠다.

RabbitMQ 구성하면서 어려웠던거

· 7 min read

사내 프로젝트에서 서비스간 비동기 메시지 통신이 필요한 상황이 생겨서 RabbitMQ를 도입하게 되었다. 적용하면서 개념적으로 헷갈렸던 부분들과 실제로 마주한 문제들을 정리해보자.

RabbitMQ 기본 구조

먼저 RabbitMQ의 핵심 구성요소를 짚고 넘어가자.

Producer → Exchange → Binding → Queue → Consumer
  1. Producer: 메시지를 발행하는 주체
  2. Exchange: 메시지를 받아서 적절한 Queue로 라우팅하는 역할
  3. Binding: ExchangeQueue를 연결하는 규칙
  4. Queue: 메시지가 실제로 쌓이는 곳
  5. Consumer: Queue에서 메시지를 꺼내 처리하는 주체

여기서 중요한건 Exchange의 타입이다.

Exchange Type동작
DirectroutingKey가 정확히 일치하는 Queue로 전달
TopicroutingKey 패턴 매칭 (*, # 와일드카드)
Fanout바인딩된 모든 Queue에 브로드캐스트
Headers헤더 값 기반 매칭

나는 Topic Exchange를 사용했다. 서비스별로 routingKey 패턴을 다르게 설정해서 유연하게 라우팅하고 싶었기 때문이다.

Queue는 누가 만들어야 하는가?

처음에 제일 헷갈렸던 부분이다. A가 Publish(발행)하고 B가 Consume(구독)한다면, Queue를 만드는 주체는 누구일까?

결론부터 말하면 Consumer가 만드는게 맞다.

RabbitMQ 기본 철학: "발행자는 Queue 이름을 몰라야 한다." Queue는 Consumer가 선언해야 한다. Producer는 Exchange + Routing Key만 알면 충분하다.

소비 시나리오를 정리하면 다음과 같다.

  1. B 서버가 실행됨
  2. "나는 APT001/B1 데이터를 소비한다" → Queue + Binding 생성
  3. A 서버는 routingKey만 보고 발행
  4. RabbitMQ가 만들어진 Queue로 메시지 라우팅
  5. B 서버가 안정적으로 소비

하지만 이 구조에서 문제가 발생했다. B 서버가 아직 실행되지 않은 상태에서 A가 메시지를 발행하면, Queue가 존재하지 않으므로 메시지가 유실된다. 이 부분은 뒤에서 다시 다루겠다.

Spring Boot 설정

의존성

dependencies {
implementation("org.springframework.boot:spring-boot-starter-amqp")
}

application.yml

spring:
rabbitmq:
host: localhost
port: 5672
username: guest
password: guest

RabbitMQ Config

@Configuration
class RabbitMQConfig {

@Bean
fun topicExchange(): TopicExchange {
return TopicExchange("parking.exchange")
}

@Bean
fun queue(): Queue {
return Queue("parking.event.queue", true) // durable = true
}

@Bean
fun binding(queue: Queue, exchange: TopicExchange): Binding {
return BindingBuilder
.bind(queue)
.to(exchange)
.with("parking.event.#") // Topic 패턴
}

@Bean
fun messageConverter(): MessageConverter {
return Jackson2JsonMessageConverter()
}

@Bean
fun rabbitTemplate(
connectionFactory: ConnectionFactory,
messageConverter: MessageConverter,
): RabbitTemplate {
val template = RabbitTemplate(connectionFactory)
template.messageConverter = messageConverter
return template
}
}

Jackson2JsonMessageConverter를 설정해두면 객체를 자동으로 JSON으로 변환해서 메시지를 보내준다. 이거 안 하면 기본 SimpleMessageConverter가 사용되는데, 바이트 배열로 전송되어서 디버깅이 힘들어진다.

Producer

@Service
class ParkingEventPublisher(
private val rabbitTemplate: RabbitTemplate,
) {

fun publishEvent(event: ParkingEvent) {
rabbitTemplate.convertAndSend(
"parking.exchange",
"parking.event.inout",
event
)
}
}

Consumer

@Service
class ParkingEventConsumer {

@RabbitListener(queues = ["parking.event.queue"])
fun handleEvent(event: ParkingEvent) {
// 메시지 처리 로직
println("Received: ${event.ticketNo}")
}
}

마주한 문제들

1. 메시지 유실

위에서 언급한대로 Consumer가 아직 실행되지 않은 상태에서 메시지가 발행되면 유실된다. 이를 방지하려면 Queuedurable로 설정하고, Exchange와 Binding도 미리 선언되어 있어야한다.

Queue("parking.event.queue", true) // durable = true

하지만 이것만으로는 부족하다. Producer에서 메시지를 보낼때 deliveryModePERSISTENT로 설정해야 RabbitMQ가 디스크에 저장한다.

rabbitTemplate.convertAndSend("parking.exchange", "parking.event.inout", event) { message ->
message.messageProperties.deliveryMode = MessageDeliveryMode.PERSISTENT
message
}

2. Consumer 예외 발생 시 무한 재시도

Consumer에서 예외가 발생하면 기본적으로 RabbitMQ가 메시지를 다시 Queue에 넣는다. 이게 무한 반복되면서 로그가 폭주하는 상황이 발생했다.

해결 방법으로 retry 설정을 추가했다.

spring:
rabbitmq:
listener:
simple:
retry:
enabled: true
max-attempts: 3
initial-interval: 1000
multiplier: 2.0
max-interval: 10000

3번 재시도 후에도 실패하면 메시지를 버리거나, Dead Letter Queue로 보내도록 설정하는게 좋다.

3. 직렬화/역직렬화 문제

Producer와 Consumer의 DTO 패키지 경로가 다르면 역직렬화에 실패한다. Jackson2JsonMessageConverter를 사용하더라도 TypeId 헤더에 클래스 풀네임이 들어가기 때문이다.

@Bean
fun messageConverter(): MessageConverter {
val converter = Jackson2JsonMessageConverter()
converter.setTypePrecedence(Jackson2JavaTypeMapper.TypePrecedence.INFERRED)
return converter
}

TypePrecedence.INFERRED로 설정하면 헤더의 TypeId 대신 메서드 파라미터 타입을 기준으로 역직렬화한다. 멀티모듈 구조에서는 이 설정이 거의 필수다.

Docker로 RabbitMQ 띄우기

로컬 개발 환경에서는 Docker로 간단하게 띄울수있다.

# docker-compose.yml
services:
rabbitmq:
image: rabbitmq:3-management
ports:
- "5672:5672"
- "15672:15672"
environment:
RABBITMQ_DEFAULT_USER: guest
RABBITMQ_DEFAULT_PASS: guest

15672 포트로 접속하면 Management UI를 사용할수있다. Queue 상태, 메시지 수, Consumer 연결 상태 등을 실시간으로 확인할수있어서 디버깅할때 정말 유용하다.

마무리

RabbitMQ를 처음 도입할때 가장 어려웠던건 "누가 Queue를 만드는가" 같은 설계적인 고민이었다. 코드 자체는 Spring AMQP가 잘 추상화 해놔서 어렵지 않았지만, 메시지 유실이나 재시도 정책 같은 운영 관점의 이슈들은 직접 겪어봐야 감이 잡히는것같다. 특히 직렬화 문제는 멀티모듈 환경에서 반드시 한번쯤 마주치게 되니까 미리 설정해두자.

자바로 배우는 자료구조 알고리즘 정리

· 22 min read

이 내용은 '자바로 배우는 자료구조 알고리즘' 책을 읽고 내용을 정리한 내용이다. 해당 내용에 대한 실습 및 예제 코드는 아래 깃허브 링크를 참고하면 된다.

깃허브 Repo

LinkedList 클래스

ArrayList의 이점은 get, set 메서드에서 나온다. LinkedList는 심지어 이중연결리스트인 경우에도 선형 시간이 필요하다.

만약 프로그램의 실행시간이 get, set에 의존한다면 ArrayList는 좋은 선택이다, 실행시간이 시작, 끝 근처의 요소에 추가 삭제 연산에 의존한다면 LinkedListrk 좋은 선택이다.

이외에 고려할 요소는

  1. 연산이 응용프로그램의 실행시간에 뚜렷한 영향을 미치지 않는다면 List 구현에 대한 선택은 의미없음
  2. 작업하는 리스트가 매우 크지않으면 큰 성능차이를 얻기 어렵다. 작은 문제에서는 이차 알고리즘이 선형알고리즘보다 빠르기도 하고, 또 선형알고리즘이 상수시간보다 빠르기도 하다. 즉 작은 문제에서는 이러한 차이가 큰 의미를 두지 않는다.
  3. 공간에 대해서도 잊지 말아야한다. ArrayList에서는 배열 기반으로 요소들은 한덩어리의 메모리안에서 나란히 저장되어 거의 낭비되는 공간이 없고, 컴퓨터 하드웨어도 연속된 공간에서 연산속도가 종종 더 빠르다. LinkedList에서 각 요소는 하나 혹은 두개의 참조가 있는 노드가 필요하고, 참조는 공간을 차지한다. 또한 메모리 여기저기에 노드가 흩어지면 하드웨어의 효율이 떨어진다.

성능 측정

JavaFX 라이브러리 사용해서 한번 그려보자.

트리순회

Q: Stack과 같은 자료형은 List보다 더 적은 메서드를 가지는데 굳이 써야하는가?

  1. 메서드 개수를 작게 유지하면, 코드의 가독성 향상, 오류 발생 가능성 감소
  2. 자료구조에서 작은 API를 제공하면, 효율적인 구현이 쉬움. 스택을 구현하는 단순한 방법은 단일 연결 리스트를 사용. 요소를 스택에 push하면 리스트의 시작에 요소를 추가. pop하면 시작에서 요소를 제거. 연결리스트에서 시작에 요소를 추가 삭제 하는건 상수시간 연산이므로 구현이 효율적임
  3. ArrayDeque나 Deque 인터페이스를 구현한 클래스를 사용하는것이 가장 좋은 방법

철학으로 가는길

Iterable 과 Iterator

Iterable, Iterator는 컬렉션을 순회하기위해 사용하는 두가지 중요한 인터페이스다.

iterable 상속 구조

iterable 상속 구조

Iterable

자바에서 반복가능한 객체를 나타내는 인터페이스. 이를 구현한 클래스로는 for-each문을 사용하여 요소 순회가 가능. Iterator<T> iterator() : Iterable 인터페이스의 핵심 메서드로, 이를 통해 Iterator 객체를 반환한다. 이 메서드를 구현함으로써, 컬렉션 클래스는 반복 가능한 객체로 사용할 수 있게 된다.

import java.util.ArrayList;
import java.util.List;

public class IterableExample {
public static void main(String[] args) {
List<String> list = new ArrayList<>();
list.add("apple");
list.add("banana");
list.add("cherry");

// for-each 문을 사용하여 순회
for (String item : list) {
System.out.println(item);
}
}
}

Iterator 인터페이스

이터레이터는 컬렉션의 요소를 순차접근하기위한 인터페이스로 Iterator는 Iterable이 제공한 컬렉션을 직접 탐색하며, 각 요소에 접근할수있는 방법을 제공한다. Iterator를 사용하면 컬렉션의 각요소를 직접 제어하며 순회 가능하다.

  • boolean hasNext() : 다음 요소가 있는지 확인한다. 요소가 남아 있으면 true, 아니면 false를 반환한다.
  • T next() : 다음 요소를 반환하며, 현재 위치를 다음으로 이동시킨다.
  • void remove() : 현재 Iterator가 가리키고 있는 요소를 제거한다. (선택적 메서드로, 모든 Iterator에서 반드시 구현해야 하는 것은 아니다)
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;

public class IteratorExample {
public static void main(String[] args) {
List<String> list = new ArrayList<>();
list.add("apple");
list.add("banana");
list.add("cherry");

// Iterator를 사용하여 순회
Iterator<String> iter = list.iterator(); // iterator 객체를 얻는다
while (iter.hasNext()) {
String item = iter.next();
System.out.println(item);
}
}
}

위의 코드에서 iterator() 메서드를 호출하여 Iterator 객체를 얻고, while 문을 사용해 hasNext()로 다음 요소가 있는지 확인하면서 next()를 통해 요소를 가져온다.

둘의 차이점

  • Iterable : 순회 할수있는 객체를 의미, Iterator() 메서드를 통해 Iterator를 생성, for-each를 통해 컬렉션을 간결하게 순회가능
  • Iterator : 실제로 순회작업을 수행하는 객체로 컬렉션요소에 하나씩 접근 가능, 직접 요소를 순회하며 특정조건에 따라 요소를 제거하거나 순회 과정을 세밀하게 제어 가능

DFS 구현 방법 2가지

  • DFS는 깊이우선탐색으로 트리의 루트에서 시작하여 첫번째 자식노드를 선택하고 선택된 노드가 자식을 가지고 있다면 첫번째 자식을 다시선택, 반복후 자식이 없는 노드에 도착하면 부모노드로 거슬러 올라가 다음자식이 있다면 그쪽으로 이동하는 식으로 동작한다.
    dfs 순서

    dfs 순서

방법1. 재귀적 방법

메서드의 루트에서 시작해서 트리에 있는 모든 노드를 호출할때까지 반복된다. 아래 구현 코드를 보면 자식노드를 탐색하기전 TextNode의 내용을 출력하므로 전위순회에 해당한다.

private static void recursiveDFS(Node node) {  
if (node instanceof TextNode) {
System.out.print(node);
}
for (Node child: node.childNodes()) {
recursiveDFS(child);
}
}

방법2. 반복적 방법

반복적 방법으로 구현을 할때는 Stack 자료구조를 통해 구현이 가능하다. 반복적인 방법으로 구현을 알아보기에 앞서서 스택이라는 자료구조를 아래에 간단히 정리해보자.

스택은 리스트와 유사한 자료구조형으로 요소의 순서를 유지하는 컬렉션이다. 스택의 pop메서드는 항상 최상단의 요소를 반환하므로 스택은 LIFO 자료구조 이다. 이에 반해 큐 자료구조는 FIFO 이다. 일반적인 규약에서 제공하는 스택의 메서드는 다음과 같다.

  • push : 스택의 최상단에 요소를 추가
  • pop : 스택의 최상단에 있는 요소를 제거하고 반환
  • peek : 최상단 요소를 반환하지만 요소는 그대로 둠
  • isEmpty : 스택이 비었는지 알려줌
private static void iterativeDFS(Node root) {  
Deque<Node> stack = new ArrayDeque<Node>();
stack.push(root);

while (!stack.isEmpty()) {

Node node = stack.pop();
if (node instanceof TextNode) {
System.out.println(node);
}

List<Node> nodes = new ArrayList<Node>(node.childNodes());
Collections.reverse(nodes);

for (Node child: nodes) {
stack.push(child);
}
}
}

인덱스

Index는 인덱스란 검색 속도를 향상시키기 위한 자료구조이다.

인덱스는 조회 연산이다. 가장 단순한 구현은 페이지의 컬렉션이다. 검색어가 주어지면 페이지의 내용을 반복 조사하여 검색어를 포함하는 페이지를 선택하는 방법이다. 하지만 실행시간이 모든 페이지의 전체 단어수에 비례하여 매우 느리다.

Map 과 Set 자료구조

이보다 나은 대안으로 Map 자료구조를 활용해보자. Map은 키-값 쌍의 컬렉션으로 키와 키에 해당하는 값을 찾는 빠른 방법을 제공한다. 자바의 Map 인터페이스는 맵을 구현하는데 필요한 메서드를 정의한다. 자바 Map 인터페이스는 몇가지의 구현을 제공하는데 이중 HashMap, TreeMap 두 클래스를 집중적으로 알아볼 것이다.

  • get(key) : 키를 조사하여 관련된 값을 반환
  • put(key,value) : Map에 새로운 키-값을 추가하거나 이미 있는 키면 값을 업데이트 한다.

LinearMap을 구현한뒤 HashMap, TreeMap과의 성능을 비교해보자. 그전에 Entry에 대해서 알아보자. 엔트리는 아래와 같이 설명할수있다.

Java의 Map.Entry는 Map 인터페이스 내부에 정의된 중첩 인터페이스(Inner Interface)로, Map에 저장되는 키-값(Key-Value) 쌍 그 자체를 다루는 객체이다. entrySet() 메서드를 통해 Map의 각 요소를 쌍으로 순회하거나, 키와 값을 정렬 및 조작하는 데 주로 사용된다.

Map인터페이스내부의 Entry

Map인터페이스내부의 Entry

Entry는 단지 키와 값의 컨테이너로 Map클래스에 중첩되어 있으므로 같은 타입 파라미터인 K,V를 사용한다. LinearMap의 핵심 로직은 findEntry(), equals()에 있다. put, get, remove와 같은 메서드는 findEntry를 호출하여 구현한다.

public class MyLinearMap<K, V> implements Map<K, V> {  
private List<Entry> entries = new ArrayList<Entry>();

...
}
private Entry findEntry(Object target) {  
for (Entry entry: entries) {
if (equals(target, entry.getKey())) {
return entry;
}
}
return null;
}

private boolean equals(Object target, Object obj) {
if (target == null) {
return obj == null;
}
return target.equals(obj);
}

equals 메서드의 실행시간은 target과 key에 의존하지만 엔트리 개수에 해당하는 n에는 의존하지 않는다. 즉 equals는 상수시간이다. 그리고 findEntry 메서드는 운좋으면 첫번째에 원하는 키를 찾을수도있지만, n(엔트리 개수)에 비례하므로 선형시간의 연산이된다. put,get,remove와 같은 메서드들이 findEntry를 사용한다고 했으므로, 이 메서드들은 선형시간의 연산이다. 전체적인 LinearMap의 구현은 repository를 확인해보자.

이전에 작성한 MyLinearMap의 성능을 향상시킨 버전인 MyBetterMap 클래스를 만들고 테스트해보자. 먼저 엔트리를 하나의 커다란 List에 저장하는 대신 다수의 작은 리스트로 쪼개고, 각 키에 대해서 해시코드를 사용해서 어느 리스트를 사용할지 선택하는 방식으로 구현한다.

public class MyBetterMap<K, V> implements Map<K, V> {  
protected List<MyLinearMap<K, V>> maps;

...
}

MyBetterMap에서는 내장된 맵에 따라 리스트를 나누므로 각 맵별로 엔트리 개수가 줄어든다. 이부분이 findEntry 메서드와 이를 호출하는 메서드의 속도를 빠르게 해준다.

내가 만든 MyLinearMap의 선형시간을 개선하기 위해 Hashing을 사용해보자. 해싱은 임의의 길이를 가진 데이터를 고정된 길이의 고유한 데이터로 변환하는 방법이다. 이함수는 Object 객체를 인자로 받아 해시코드라는 정수로 반환한다. 중요한 점은 같은 Object 객체에 대해서 항상 같은 해시코드를 반환해야한다. 이렇게 해시코드를 사용하여 키를 저장하면, 키를 조회할때 항상 같은 해시코드를 얻게 된다.

해싱

해싱

자바에서 모든 Object 객체는 hashCode라는 메서드를 제공하여 해시함수를 계산한다. MyBetterMap에서는chooseMap() 메서드에서 해시코드를 사용한다. 이 메서드는 키에 대한 적합한 하위 맵을 고르는 헬퍼 메서드이다.

Object.hashCode

Object.hashCode

protected MyLinearMap<K, V> chooseMap(Object key) {  
int index = key==null ? 0 : Math.abs(key.hashCode()) % maps.size();
return maps.get(index);
}

hashCode 메서드를 호출해서 정수를 얻고, Math.abs 메서드를 호출해서 절대값을 만든뒤 나머지 연산을 통해 결과가 0에서 map.size()-1 사이의 값임을 보장한다. 이에따라 index는 항상 maps의 유효한 인덱스가 되고, chooseMap은 선택한 맵의 참조를 반환한다. 이에 대한 성능은 n개의 엔트리를 k개의 하위 맵으로 나누면 맵당 엔트리는 평균 n/k개가 된다. 키를 조회할때 해시코드를 계산해야하는데 이때 시간이 조금 추가된다. 그다음에 키에 맞는 하위맵을 검색한다. 이를 통해 MyBetterMap은 MyLinear맵에 비해 k배 빨라졌다. 하지만 실행시간은 여전히 n에 비례하므로 선형시간이다.

해싱에 대해서 조금더 깊이 알아보자. 해시함수의 근본적인 요구사항은 같은 객체는 매번 같은 해시코드를 만들어야한다는 것이다. 불변객체(Immutable Object)일때는 상대적으로 쉽지만, 가변객체(Mutable Object)일때는 좀더 고민이 필요하다.

불변 객체의 예시로 String을 캡슐화하는 SillyString을 정의해보자

  public class SillyString {  
private final String innerString;

public SillyString(String innerString) {
this.innerString = innerString;
}

public String toString() {
return innerString;
}

@Override
public int hashCode() {
int total = 0;
for(int i=0;i<innerString.length();i++) {
total += innerString.charAt(i);
}
return total;
}

@Override
public boolean equals(Object obj) {
return this.toString().equals(obj.toString());
}
}

SillyString클래스는 equals, hashCode를 오버라이드 하여 동작한다. 제대로 동작하려면 equals메서드는 hashCode메서드와 일치해야한다. 이는 두객체가 같다면(equals메서드가 true를 반환하면) 두객체의 해시코드 또한 같아야한다. 이는 단방향으로 두객체의 해시코드가 같더라도 그들이 같은 객체일 필요는 없다.

위에서 구현한 해시함수는 정확하게 동작하지만 좋은 성능을 보장하진않는다. 왜냐하면 많은 서로다른 문자열을 위해 같은 해시코드를 반환하기 때문이다. 두 문자열에 같은 문자가 순서만 다르게 포함되어있다면 이들은 해시코드가 같아진다. 예시로 'ac', 'ca' , 'bb' 는 모두 같은 해시코드를 같는다.

많은 객체가 동일한 해시코드를 가지면 같은 하위 맵으로 몰리게 된다. 어떤 하위맵에 다른맵보다 많은 엔트리가 있으면 k개의 하위맵으로 인한 성능향상이 k보다 줄어들게 된다. 그래서 해시함수의 목표중 하나는 균등함이다. 즉 일정 범위에 있는 어떤 값으로 골고루 퍼지도록 해시코드가 생성되게 설계해야한다.

String 클래스는 불변이고, innerString 변수가 final로 선언되어 SillyString 클래스 또한 불변이다. 일단 SillyString 객체가 생성되면 innerString 변수는 다른 String 객체를 참조할수없고 이변수가 참조하는 String 객체도 변경할수없다. 즉 항상 같은 해시코드를 같게 된다. 하지만 가변 객체라면 어떨까?

public class SillyArray {  

private final char[] array;

public SillyArray(char[] array) {
this.array = array;
}

public String toString() {
return Arrays.toString(array);
}

@Override
public int hashCode() {
int total = 0;
for (int i = 0; i < array.length; i++) {
total += array[i];
}
System.out.println("code : " + total);
return total;
}

@Override
public boolean equals(Object obj) {
return this.toString().equals(obj.toString());
}

public void setChar(int i, char c) {
this.array[i] = c;
}
}

SillyArray클래스는 위와같다. 인스턴스 변수로 String이 아닌 문자 배열을 사용하는데, SillyArray클래스는 setChar 메서드를 통해 배열에 있는 문자를 변경할수있다. 간단한 예제를 살펴보자.

SillyArray array = new SillyArray("abc".toCharArray());  
MyBetterMap<SillyArray, Integer> map = new MyBetterMap<SillyArray, Integer>();
map.put(array, 1);

array.setChar(0, 'd'); //값 변경
map.get(array);

결과

결과

변경후 해시코드가 변경된다. 해시코드가 달라서 잘못된 하위맵을 조회할수도 있는것이다. 이러한 상황에서는 키가 맵에 있어도 찾을수없게된다. 일반적으로 해싱을 사용하는 자료구조에서 가변객체를 키로 사용하는것은 위험하다. 키가 맵에 있는동안 변형되지 않는다고 보장할수있거나 어떤 변화가 해시코드에 영향을 미치지않아야한다.

다음으로 Set 인터페이스에 대해서 알아보자. 교집합 연산이 필요한 경우 Set을 사용할수있다. Set은 실제 교집합 연산을 제공하지는 않지만 교집합연산과 다른 집합 연산을 효율적으로 구현 할수있는 메서드를 제공한다.

  • add(element) : 집합에 요소를 추가, 동일한 집합이 있다면 효과가 없음
  • contains(element) : 주어진 요소가 집합에 포함되어 있는지 확인

자바는 HashSet, TreeSet 클래스로 Set인터페이스의 구현을 제공한다.

개인프로젝트에 AI 코드리뷰를 도입해보자 (feat. 코드래빗)

· 6 min read

개인 프로젝트를 진행하면서 백엔드를 혼자 구현하다보니 코드리뷰가 불가능한 상황이였다. 이전에 만들었던 딥시크 기반 코드리뷰어를 사용해 볼까 하다가, 써보자 하고 계속 미뤄졌던 coderabbit서비스를 한번 도입해 보기로 하였다. 구현 과정에서 놓친 부분을 AI로 보완하고, 추가적인 학습 인사이트를 얻을 수있었던 경험을 글로 남겨보려고한다.

코드래빗이 뭔가요?

코드레빗 소개

코드레빗 소개

위의 이미지는 코드래빗 공식문서에 나와있는 내용이다. 간단히 말해 AI를 통해 코드 검토를 진행하는 AI코드 리뷰어 라고 할수있다.

적용 방법

  1. 코드래빗 사이트에서 Github 계정으로 로그인을 한다.

메인 화면

메인 화면

  1. 로그인 후 코드래빗 적용을 원하는 repository를 선택한다.

레포지토리 선택

레포지토리 선택

  1. 적용이 끝났다. 이제 설정해둔 Repo에 PR이 올라오면 자동으로 코드래빗이 리뷰를 달아준다. 만약 PR을 올려도 동작하지 않는다면 4번을 따라하자.

  2. Organization Settings > Configurations > Review > Auto Review > Drafts에 BaseBranch 설정을 해주자. 이때 설정은 .*을 넣어주면 된다.

    설정1

    설정1

    설정2

    설정2

  3. 결과를 보면 정말 상세하게 리뷰를 잘달아준다. 특히 3번째 이미지를 보면 리뷰에 남긴 댓글에 대댓글까지 남겨준다.

    walkthrough 펼쳐보기

    walkthrough 펼쳐보기

    다이어그램

    다이어그램

    댓글

    댓글

상세 조정

코드래빗 설정 페이지의 Organization Settings 메뉴에서 조직의 설정을 하는것이 가능하지만, 누군가는 각 레포지토리 별로 상세 설정을 하고 싶을수있다. 이런경우 YAML을 이용해서 코드래빗을 구성하는것도 가능하다.

파일의 위치는 루트 폴더에 .coderabbit.yaml으로 배치하면 작성해둔 커스텀한 설정이 반영된다. 파일 설정 방법은 아래의 공식문서에 자세히 나와있으니 참고해서 구성해보자.

(예시)

# ---------------------------------------------------------------- #
# AI 기본 설정 (언어 및 페르소나)
# ---------------------------------------------------------------- #

# AI가 응답할 때 사용할 기본 언어를 설정합니다. (예: ko-KR, en-US)
language: ko-KR
# AI의 페르소나와 응답 톤앤매너를 상세하게 지시합니다. 최대 200자까지 작성할 수 있습니다.
tone_instructions: >
당신은 대한민국 1등 백엔드 개발자입니다. 목표는 현재 진행중인 프로젝트의 코드 품질을 개선하고 적용할수있도록 돕는것입니다.
1. 피드백은 명확하고 구체적이어야 하며, 문제의 원인과 개선 방법을 반드시 제시하세요.
2. 리뷰는 교육적이어야 하며, 관련 개념이나 공식 문서를 함께 추천하세요.
3. 비판보다는 개선 중심의 제안을 우선하세요.
4. 칭찬은 짧고 위트 있게 작성하세요.

# ---------------------------------------------------------------- #
# 자동 리뷰 트리거 설정
# ---------------------------------------------------------------- #
auto_review:
# PR이 생성됐을 때
# `@coderabbitai review`를 코멘트에 입력하면 리뷰를 시작합니다.
enabled: false
# 이미 리뷰가 진행된 PR에 새로운 커밋이 추가될 때, 변경된 부분에 대해서만 자동으로 리뷰를 진행할지 여부입니다.
auto_incremental_review: false

# ---------------------------------------------------------------- #
# CodeRabbit의 지식 기반(Knowledge Base) 설정
# ---------------------------------------------------------------- #
knowledge_base:
# 웹 검색을 통해 최신 정보나 문서를 참조할 수 있도록 허용할지 여부입니다.
web_search:
enabled: true
# 레포지토리 내의 특정 파일을 AI의 코드 스타일 가이드라인으로 사용하도록 설정합니다.
code_guidelines:
enabled: true
filePatterns:
- docs/be-code-convention.md
- docs/fe-code-convention.md
# CodeRabbit이 대화나 리뷰를 통해 학습한 내용을 어디에 저장하고 참조할지 범위를 설정합니다. 'local'은 현재 레포지토리에 한정됨을 의미합니다.
learnings:
scope: local
# 이슈 정보를 참조할 범위를 설정합니다.
issues:
scope: local
# 풀 리퀘스트 정보를 참조할 범위를 설정합니다.

API 아키텍처s (feat. WSDL 연동하기...)

· 10 min read

백앤드 개발을 하면서 RESTful한 API은 자주 사용하였지만, 다른 API 아키텍처를 반영하는 경우는 거의 없었다. 특히 SOAP과 같은 조금은 오래된 기술들은 개념적으로 잠깐 찾아본적은 있어도 동작 방식이나 사용방법은 전혀 알지 못했다. 최근에 진행한 프로젝트에서 타사 프로그램을 연동하는 과정에서 WSDL를 통해 연동을 진행했는데 이때 배운 내용을 기록하고자 블로그를 작성한다.

다양한 아키텍처

둘 이상의 개별 애플리케이션이 통신하기 위해 개발자는 한시스템에서 다른 시스템의 정보나 기능에 접근할수있도록 API (Application Programming Interface) 라는 브릿지를 구축한다. 다양한 애플리케이션을 빠르고 대규모로 통합하기 위해서는 프로토콜 or Spec에 맞게 네트워크를 통해 전달되는 메세지의 의미, 구문을 정의한다. 이러한 사양이 바로 API 아키텍처이다.

시간이 지나가며 API 아키텍처도 다양한 방식으로 고도화되고 있다. 아래의 그림은 API 아키텍처의 발전과정을 볼수있는 타임라인이다.

출처 : Rob Crowley

출처 : Rob Crowley

각 아키텍처마다 데이터 교환에 대한 고유한 표준화 패턴을 가지고 있으며, 선택의 폭이 넓어짐에 따라 어떤 아키텍처가 적합한지에 대해서는 다양한 의견이 있고 현대의 API는 대부분 REST를 가리킨다. 하지만 기술의 실제 속성과 특성을 고려하지 않은채 기술 자체를 인기에 따라 편향적으로 선택하는 것은 지양해야한다. 자 그럼 본론에 들어가기 전에 간단하게 아래 그림을 확인해보자.

architecture

architecture

REST

REST는 Representational State Transfer의 약자로, 2000년도에 로이필딩 박사를 통해 웹의 장점을 최대한 활용할 수 있는 아키텍처로써 REST가 발표되었다. 이러한 REST는 아래 3가지로 구성된다.

  • 자원(Resource) - URI
  • 행위(Verb) - HTTP Method
  • 표현(Representations)

REST 아키텍처는 제한 조건을 준수하며 각 개별 컴포넌트는 제한조건안에서 자유롭게 구현이 가능하다. 아래의 6가지 제한조건을 확인해보자.

  1. 인터페이스 일관성
    • URI로 지정한 리소스에 대한 조작을 통일되고 한정적인 인터페이스로 수행하는 아키텍처 스타일
  2. 무상태(stateless)
    • 작업을 위한 상태정보를 따로 저장하고 관리하지 않는다.
    • 세션정보, 쿠키 정보를 별도로 저장 관리 하지않아 API 서버는 들어오는 요청만을 단순히 처리한다.
    • 서비스 자유도 상승, 서버에서 불필요한 정보 관리 X
  3. 캐시 처리 가능(cacheable)
    •  HTTP라는 기존 웹표준을 그대로 사용하여 HTTP의 캐싱기능 사용이 가능
    • HTTP 프로토콜 표준에서 사용하는 Last-Modified 태그나 E-Tag를 이용하면 캐싱 구현이 가능
  4. 계층화(layer system)
    • REST 서버는 다중 계층으로 구성될 수 있으며 보안, 로드 밸런싱, 암호화 계층을 추가해 구조상의 유연성을 둘 수 있고 PROXY, 게이트웨이 같은 네트워크 기반의 중간매체를 사용할 수 있게 한다.
  5. 클라이언트/서버 구조
    • REST 서버는 API 제공, 클라이언트는 사용자 인증이나 컨텍스트(세션, 로그인 정보)등을 직접 관리하는 구조
    • 각각의 역할이 확실히 구분되기 때문에 클라이언트와 서버에서 개발해야 할 내용이 명확해지고 서로간 의존성이 줄어든다.
  6. Code on demand (optional)
    • 자바 애플릿이나 자바스크립트의 제공을 통해 서버가 클라이언트가 실행시킬 수 있는 로직을 전송하여 기능을 확장시킬 수 있다.

이러한 내용을 바탕으로 REST API를 디자인 할때 URI는 정보의 자원을 표현해야하며, 자원에 대한 행위는 HTTP Method로 표현하는 2가지의 내용을 꼭 기억자.

SOAP

SOAP(Simple Object Access Protocol)은 네트워크를 통해 구조화된 정보를 교환하기 위한 XML 기반의 프로토콜이다. SOAP API는 일반적으로 HTTP 프로토콜을 사용하지만 SMTP, TCP와 같은 프로토콜 사용도 가능하다. SOAP API는 높은 수준의 프로토콜 추상화를 제공하고 암호화 / 트랜잭션 관리와 같은 고급 기능을 지원한다. 은행 및 금융 도메인 등에서 보안을 이유로 SOAP을 사용한다.

SOAP API의 로직은 웹서비스 기술언어인 WSDL로 작성된다. WSDL은 엔드포인트를 정의하고 수행가능한 모든 프로세스를 설명한다. XML 데이터 형식은 많은 제약을 요구하는데 아래 이미지를 참고해보자.

SOAP 예시

SOAP 예시

  • 모든 메세지를 시작하고 끝내는 envelope 태그
  • 요청과 응답을 포함하는 본문
  • 메세지에 특정 사항이나 추가 요구하사항을 결정하는 헤더
  • 요청 처리과정 전반에 걸쳐 발생할수있는 오류

이러한 SOAP은 언어와 플랫폼에 구애받지 않고 다양한 전송 프로토콜에 바인딩이 가능하다. 또한 오류 처리 기능이 내장되어 Retry XML 로 메세지 반환이 가능하며 다양한 보안 확장 기능을 제공한다. 하지만 XML 구조만 지원하며 XML파일 기반의 단점인 파일 크기가 커서 비용이 증가한다. 또한 생태계가 작아 레퍼런스를 찾기가 어렵다. 이러한 이유로 SOAP 아키텍처는 기업 내부에서 통합을 진행하거나, 신뢰성 있는 파트너와의 연동에서 사용된다.

WSDL에 대해 좀더 자세히 알아보고 싶다면 링크를 참고하자. 이번 연동 과정에서 도움을 받았던 내용이다.

결론

이번 연동을 통해 REST 중심으로 개발해오던 기존 경험에서 벗어나, 서로 다른 시스템 간 통신 방식이 어떻게 설계되고 표준화되는지를 이해할 수 있었다. 결국 API 아키텍처 선택은 기술의 최신성이나 선호도가 아니라, 연동 대상 시스템의 특성·보안 요구사항·신뢰성 수준·운영 환경 등을 종합적으로 고려해 결정해야 한다. 이번 경험을 통해 다양한 API 아키텍처의 차이를 이해하고, 특정 기술에 국한되지 않고 상황에 맞는 통신 방식을 선택하는 것이 백엔드 개발자에게 중요한 역량임을 느꼈다.

SQL Server NVARCHAR 해결

· 7 min read

프로젝트 QA중 특정 기능에서 조회 성능이 굉장히 떨어져 타임아웃까지 응답이 불가능한 문제가 발생하였다. 조회 요청을 보내는 테이블에 데이터가 꽤나 많았고, 추후 최적화를 진행하려고 했던터라 개발 단계에서 신경쓰지 못했었지만, 이번 오류 대응 과정을 통해서 배웠고, 앞으로도 주의해야할 내용을 공유해 보고자 한다.

문제점

InOutDTO agoInOut = inOutDAO.selectInOut(inoutModifyRequest.getTicketNo());

TicketNo를 통해 이전 데이터를 가져온후 다양한 service에 변경 사항을 update해야하는 내용이 있었고, update 할곳이 꽤나 많다보니 여기서 문제가 발생한다고 생각했었는데 알고보니 select 자체에서 문제가 발생하고 있었다. 형변환도 정상적으로 되고 있고, 조회도 느리긴하지만 되기 때문에 큰문제가 아니라고 생각했지만 오산이였다. DB Lock 이슈를 유발하는 SQL 문은 아래와 같다.

 <select id="selectInOut"
resultType="com.vstl.ansan.api.dto.InOutDTO">
SELECT
PH.ticketNo,
PM.parkAreaName,
PH.carNo,
PH.parkingDay,
DM.discountName AS discountName,
...
FROM parking_history PH
INNER JOIN ...
...
WHERE PH.ticketNo = #{ticketNo}
AND PH.useOk = 1
</select>

해당 Query를 모니터링 해보니 CPU를 많이 소모 하고있었다. WHERE 절에 걸려있는 #{ticketNo}는 parking_history 에서 Primary Key로 주요 INDEX가 걸려있다. DB에서 정의된 ticketNochar(13) 타입으로 정의되어있다.

SQL Server JDBC Driver 는 String 파라미터를 모두 NVARCHAR로 매핑한다. PreparedStatement.set 호출시 명시적으로 VARCHAR 매핑을 지정해도 NVARCHAR로 변환하여 매핑한다. 조회요청 이후 Lock된 session의 상태를 살펴보자.

[ 
{ "session_id": 71,
"status": "runnable",
"command": "SELECT",
...
"text":
"
(@P0 nvarchar(4000))
SELECT\n
...
WHERE PH.ticketNo = @P0 \n AND PH.useOk = 1"
} ]

 위의 내용에서 (@P0 nvarchar(4000)) 부분을 살펴보자. SQL Server JDBC Driver는 String type의 파라미터를 유니코드 타입인 nvarchar(4000) 형식으로 전달한다. MSSQL Data Type 우선순위를 보면 NVARCHAR가 VARCHAR / CHAR 보다 높기 때문에 char(13)컬럼에 대해 조회조건으로 String 파라미터를 사용하게 되면, NVARCHAR로 형변환이 일어나게 되고, 이상태로 조건을 비교한다. 이때 PK 에 걸려있는 CHAR INDEX도 무시된다.

  1. CHAR - NVARCHAR 형변환 비용 발생
  2. NVARCHAR 변환으로 인한 INDEX 무시

이 두가지 이유로 비용이 크게 증가되고, 성능 저하가 발생한다. 특히 조회하고 있는 parking_histroy테이블에는 1억건 이상의 데이터가 존재하여 많은 CPU소모가 일어나게 된것이다.

문제 해결

sendStringParametersAsUnicode=false 추가

가장 쉬운 해결방법은 아래와 같이 JDBC URL 에 sendStringParametersAsUnicode=false 를 추가하는 방법이다.

spring:
datasource:
type: org.apache.tomcat.jdbc.pool.DataSource
driverClassName: com.microsoft.sqlserver.jdbc.SQLServerDriver
url: jdbc:sqlserver://myhost:1234;database=SbSvc;sendStringParametersAsUnicode=false

이설정을 통해 모든 String 파라미터를 VARCHAR 타입으로 매핑 한다.

NVARCHAR 파라미터에 Cast 사용

NVARCHAR 파라미터에 CAST를 사용해서 VARCHAR / CHAR로 변경 시켜주면, VARCHAR, CHAR 간의 비교여서 형변환이 일어나지 않는다.

 <select id="selectInOut"
resultType="com.vstl.ansan.api.dto.InOutDTO">
SELECT
PH.ticketNo,
PM.parkAreaName,
PH.carNo,
PH.parkingDay,
DM.discountName AS discountName,
...
FROM parking_history PH
INNER JOIN ...
...
WHERE PH.ticketNo = CAST(#ticketNo# AS VARCHAR)
AND PH.useOk = 1
</select>

JPA인 경우

NVARCHAR 컬럼을 매핑한 필드에 @Nationalized를 사용하면, 해당 컬럼에 대한 조회 조건만 NVARCHAR로 쿼리가 만들어지고, 나머지 컬럼들은 VARCHAR로 매핑하게 된다.

@Nationalized
@Column(name = "ticketNo", length = 50)
private String ticketNo;

JPQL, Querydsl-JPA 모두 이규칙을 따르고 있지만, EntityManager.createNativeQuery는 이규칙을 따르지않는다. (@Query(native=true)인 경우) 그래서 이경우에는 NVARCHAR 매핑시, CONVERT(NVARCHAR, ?)를 사용해서 변환을 해줘야한다. 아니면 JdbcTemplate를 직접 사용한다.

JdbcTemplate에서 NVARCHAR를 매핑하는 방법은 아래와 같이 setNString()을 사용한다.

jdbcTemplate.query("select * from sqlserver_with_java.dbo.books where book_type = ? and title like ?",
ps -> {
ps.setString(1, bookType.name()); // VARCHAR 매핑
ps.setNString(2, title); // NVARCHAR 매핑
},
rs -> {
log.info("id , title, bookType", rs.getLong("id"), rs.getString("title"), rs.getString("book_type"));
});

보너스

SQL Server에서 Session 확인 쿼리

SELECT r.session_id, r.status, r.command, r.wait_type, r.wait_time, r.blocking_session_id,  
t.text
FROM sys.dm_exec_requests r
CROSS APPLY sys.dm_exec_sql_text(r.sql_handle) t
WHERE r.session_id <> @@SPID;

조회 결과

조회 결과

NVARCHAR로 조회가 필요한 경우는?

이경우에는 이전처럼 INDEX가 무시되거나, 성능저하가 발생하는 문제는 없을것이다. INDEX 가 NVARCHAR 인 컬럼에 VARCHAR 조건이 매핑된 경우라면, 우선순위에 따라 조건으로 들어온 Parameter 가 형변환이 일어나기 때문에 INDEX 에는 영향을 주지 않게 된다.

즉, NVARCHAR 의 경우에는 컬럼들이 모두 형변환이 일어나는 것이 아니라 파라미터만 VARCHAR 에서 NVARCHAR 로 형변환이 일어난 후 조건을 비교하기 때문에, INDEX 도 유지되고 성능에도 큰 문제가 되지 않는다.

마무리

사소해 보이는 내용이라도 한번더 확인하고 점검하자.

멀티모듈 적용

· 9 min read

MSA 전환을 염두해둔 상태에서 첫번째 스텝으로 멀티모듈에 대해서 공부하고 적용해보자.

MSA / 멀티모듈

사내의 서비스는 다음과 같이 나눠진다.

  1. API
  2. ADMIN
  3. BATCH(Gateway)
  4. WEB
  5. Cache 등

좋은 아키텍처는 시스템이 모놀리틱 구조로 태어나서 단일 파일로 배포되더라도, 이후에는 독립적으로 배포 가능한 단위의 집합으로 성장하고, 독립적인 서비스나 마이크로 서비스 수준까지 성장할수있도록 만들어져야한다. 또한 좋은 아키텍처라면 상황에 의해 진행방향을 거꾸로 돌려 원래 형태인 모놀리틱 구조로 되돌릴수 있어야한다.

멀티 모듈의 구조

먼저 멀티모듈의 구조에 대해서 살펴보자. 멀티모듈은 단일 모듈 멀티프로젝트, 단일모듈 멀티프로젝트 + 저장소 , 멀티모듈 단일 프로젝트로 나눠볼수있다.

단일 모듈 멀티 프로젝트

  • 각각의 프로젝트 단위
    • IDE를 쓴다면 3개의 IDE 화면을 띄워둔 상태로 개발을 진행하는 형태
  • Member 라는 클래스가 공유(중복)되고 있다.
    • member-internal-api에서 Member가 수정되면 나머지 프로젝트에도 수정이 필요

단일 모듈 멀티 프로젝트 + 내부 Maven 저장소

  • Nexus와 같은 사설 Maven Repository를 만들어서 각각의 프로젝트에서 공유하고 있는 DTO나 도메인 클래스들을 분리 후 프로젝트화 시켜서 Nexus에 업로드하는 방식
  • member-domainMember 수정 1회로 각각 프로젝트에 적용 가능해져 일관성이 보장
  • 개발시 귀찮은 작업이 발생
    • membr-internal-api 수정하여 Nexus 배포하고 member-domain에서 Nexus에 배포된 내용을 다운로드
    • 기능 개발 과정에서 리소스가 많이들게 됨

멀티 모듈 단일 프로젝트

  • 프로젝트는 하나고, 그안에 여러개의 모듈을 설치 가능한 방법
  • IDE 하나만 사용하여 시스템적으로 보장되는 일관성, 빠른 개발 사이클 확보가 가능

결국 세번째 방식인 멀티 모듈 단일 프로젝트가 가장 현실적이라고 판단했다. IDE 하나로 전체를 관리할수있고, 모듈간 의존성도 Gradle로 명확하게 관리되니까.

모듈 설계

사내 서비스 구조에 맞춰서 모듈을 다음과 같이 나눴다.

root
├── module-core # 공통 Entity, DTO, 유틸
├── module-api # API 서버 (외부 클라이언트 대상)
├── module-admin # ADMIN 서버 (내부 관리자 대상)
├── module-batch # Batch 처리
└── module-web # 웹 프론트엔드 서빙

모듈을 나누는 기준이 좀 고민됐는데, 핵심은 공유 코드를 어디에 둘것인가였다. Entity, Repository, 공통 DTO 같은건 모든 모듈이 필요로 하니까 module-core에 넣고, 나머지 모듈들이 core를 의존하는 구조로 잡았다.

Gradle 설정

settings.gradle.kts

rootProject.name = "my-project"

include("module-core")
include("module-api")
include("module-admin")
include("module-batch")

루트 build.gradle.kts

plugins {
java
id("org.springframework.boot") version "3.2.0"
id("io.spring.dependency-management") version "1.1.4"
}

subprojects {
apply(plugin = "java")
apply(plugin = "org.springframework.boot")
apply(plugin = "io.spring.dependency-management")

group = "com.vstl"
version = "0.0.1-SNAPSHOT"

java {
sourceCompatibility = JavaVersion.VERSION_17
}

repositories {
mavenCentral()
}

dependencies {
implementation("org.springframework.boot:spring-boot-starter")
compileOnly("org.projectlombok:lombok")
annotationProcessor("org.projectlombok:lombok")
testImplementation("org.springframework.boot:spring-boot-starter-test")
}
}

subprojects 블록에 공통 설정을 넣으면 모든 모듈에 적용된다. 모듈별로 추가 의존성이 필요하면 각 모듈의 build.gradle.kts에서 개별로 추가하면 된다.

module-core/build.gradle.kts

// core는 독립 실행하지 않으므로 bootJar 비활성화
tasks.bootJar { enabled = false }
tasks.jar { enabled = true }

dependencies {
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
}

여기서 중요한게 bootJar를 비활성화하는 부분이다. core 모듈은 단독으로 실행되는 애플리케이션이 아니라 라이브러리처럼 동작해야하므로, bootJar 대신 일반 jar로 패키징해야한다. 이거 안 해주면 빌드할때 메인 클래스를 찾을수없다는 에러가 발생한다.

module-api/build.gradle.kts

dependencies {
implementation(project(":module-core"))
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-security")
}

implementation(project(":module-core"))core 모듈을 의존하면 Entity, Repository 등을 바로 사용할수있다.

적용 중 마주한 문제들

1. Entity Scan 안되는 문제

module-api에서 module-coreEntity를 인식하지 못하는 문제가 발생했다. 원인은 @SpringBootApplication이 자신의 패키지 하위만 스캔하기 때문이다.

// module-api의 메인 클래스
@SpringBootApplication(scanBasePackages = "com.vstl")
@EntityScan(basePackages = "com.vstl.core")
@EnableJpaRepositories(basePackages = "com.vstl.core")
public class ApiApplication {
public static void main(String[] args) {
SpringApplication.run(ApiApplication.class, args);
}
}

scanBasePackages, @EntityScan, @EnableJpaRepositories를 명시적으로 지정해줘야 다른 모듈의 BeanEntity를 인식한다. 처음에 이걸 몰라서 한참 헤맸다.

2. 순환 의존 주의

모듈간 의존 방향은 반드시 한쪽으로만 흘러야한다.

module-api → module-core  (O)
module-core → module-api (X)

coreapi를 의존하게 되면 순환이 발생하고, Gradle 빌드 자체가 실패한다. 실무에서 가끔 core에 특정 API 전용 로직을 넣고 싶은 유혹이 생기는데, 그러면 안된다. core는 정말 공통으로 쓰이는 것만 넣어야한다.

3. 각 모듈별 application.yml 분리

모듈마다 application.yml을 가질수있다. 하지만 core에 DB 설정을 넣으면 모든 모듈이 같은 DB 설정을 쓰게되므로, DB 설정은 실행 모듈(api, admin 등)에 두는게 맞다.

module-core/src/main/resources/
└── (yml 없음 또는 공통 설정만)

module-api/src/main/resources/
└── application.yml (DB, 서버 포트 등)

module-admin/src/main/resources/
└── application.yml (다른 포트, 다른 설정)

4. 빌드 및 배포

각 모듈을 개별로 빌드하려면 다음과 같이 실행한다.

# API 모듈만 빌드
./gradlew :module-api:bootJar

# 전체 빌드
./gradlew build

Docker 이미지도 모듈별로 만들수있다.

# module-api/Dockerfile
FROM eclipse-temurin:17-jre
COPY build/libs/module-api-0.0.1-SNAPSHOT.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]
cd module-api
docker build -t my-project-api .

적용 후 느낀점

모듈을 나누니까 확실히 좋아진 부분이 있다.

  1. 코드 중복 제거: Entity, DTO를 한곳에서 관리하니까 수정이 한번으로 끝난다
  2. 빌드 속도: 변경된 모듈만 빌드하면 되니까 전체 빌드보다 빠르다
  3. 배포 독립성: API만 수정했으면 API만 배포하면 된다
  4. 관심사 분리: 어떤 코드가 어디에 속하는지 명확해졌다

하지만 주의할 점도 있다. 모듈을 너무 잘게 나누면 오히려 관리가 복잡해진다. 처음에는 core + 실행 모듈 정도로 시작하고, 필요에 따라 점진적으로 분리하는게 좋다. 그리고 모듈간 의존 방향을 항상 의식하면서 개발해야한다. 한번 꼬이면 나중에 풀기가 정말 어렵다.

NCP와 함께하는 쿠버네티스 구축

· 8 min read

이번 프로젝트를 진행하면서 Naver Cloud Platform을 이용해 쿠버네티스를 구축하고 개발하게 되었다. 클라우드로 쿠버네티스를 구축하는 방법과 구축 과정에서 만났던 많은 어려움들을 어떻게 해결해 나갔는지 기록해보려고 한다.

구축 순서

1. VPC 준비
2. Subnet 생성
3. NKS클러스터 생성
4. 노드풀 생성
5. 도메인 및 LB 설정
6. 관리용 시스템 생성
7. Bastion Host 구축
8. KubeCtl 설치

NCP 설정

VPC 준비

VPC 및 서브넷을 생성한다.

image22

image22

myPage

myPage

BastionHost 생성

kubectl을 사용하기 위해서는 bastionHost가 필요하다.

NAT 설정

NKS 클러스터가 Private 서브넷에 구성되어있어 노드가 Public Internet에 접근할수없다. NKS 노드에 외부 인터넷이 열려야지 Docker Registry등의 서비스를 사용 가능하다. NAT Gateway 설정으로 노드는 외부에 노출하지 않고, 인터넷이 가능한 환경을 구축한다.

먼저 NAT Gatway를 생성하고, NAT Gateway를 Private 서브넷의 Route Table에 등록해준다.

testImage

testImage

kkkk

kkkk

BastionHost 설정

KubeCtl 설치

이번에 구축한 서버의 경우 X86-64 이고, 혹시 ARM 기반 으로 구축을 하는경우 아래 링크를 참고하자.

kubectl 구축

  1. 최신 릴리스 다운로드
   curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl"
  1. 바이너리 검증
   curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl.sha256"
echo "$(cat kubectl.sha256)  kubectl" | sha256sum --check

명령어를 통해 정상 출력시 아래와 같은 내용이 표출 된다.

정상 출력

정상 출력

  1. kubectl 설치
sudo install -o root -g root -m 0755 kubectl /usr/local/bin/kubectl
  1. 설치된 버전 확인
kubectl version --client
  1. ncloud 접속을 위한 Config 설정
## ~/.ncloud/configure

[DEFAULT]
ncloud_access_key_id = AK_XXX
ncloud_secret_access_key = SK_XXX
ncloud_api_url = https://ncloud.apigw.gov-ntruss.com
chmod 600 ~/.ncloud/configure

현재 프로젝트에서 Docker Hub가 아닌 NCP에서 지원하는 Docker Registry를 사용중이여서 해당 부분을 Secret으로 만들어 설정해줘야한다.

  1. 도커 레지스트리 설정

Ingress 설치

Ingress Controller의 사용 방식은 2가지가 있다. 이중 내가 사용할 방식은 ALB를 생성하여 Ingress로 컨트롤 하는 방식이다.

  1. Nginx Ingress

  2. ALB Ingress

  3. Ingress Controller 설치 확인

kubectl get pods -n kube-system | grep ingress
  1. 없으면 설치를 진행
  • ALB 방식
kubectl --kubeconfig=$KUBE_CONFIG apply -f https://raw.githubusercontent.com/NaverCloudPlatform/nks-alb-ingress-controller/main/docs/install/pub/install.yaml

설치 완료시 아래와 같은 응답을 받을수 있다.

  • Nginx 방식
kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/controller-v1.10.1/deploy/static/provider/cloud/deploy.yaml

설치가 완료되면 이제 Ingress를 세팅해서 실제로 apply하는 작업을 수행해야 한다. 이를 위해 Ingress.yml을 작성하자. 자세한 설명은 하단의 링크를 참고하자. NCP - ALB Ingress 설정 방법

  1. Ingress.yml
ncloud@vstl-wm-kubectl:~$ cat watchmile-api-ingress.yaml 
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: ansan-gov-ingress
annotations:
alb.ingress.kubernetes.io/listen-ports: '[{"HTTP":80}, {"HTTPS":443}]'
alb.ingress.kubernetes.io/ssl-certificate-no: "26109"
alb.ingress.kubernetes.io/ssl-redirect: "443"
alb.ingress.kubernetes.io/load-balancer-name: ansan-kube-alb
alb.ingress.kubernetes.io/load-balancer-type: "alb"
alb.ingress.kubernetes.io/network-type: public
alb.ingress.kubernetes.io/load-balancer-size: small
alb.ingress.kubernetes.io/healthcheck-path: /actuator/health
spec:
ingressClassName: alb
tls:
- hosts:
- parkingapi.ansanuc.net
secretName: dummy-tls
rules:
- host: parking-api.ansanuc.net
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: ansan-daemin-api
port:
number: 80
- host: parkingm-api.ansanuc.net
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: ansan-admin-api
port:
number: 80

파이프 라인

사용중인 레포지토리는 Bitbucket 인데, Pipeline이라는 좋은 기능을 제공한다. Github Action 처럼 Bitbucket 자체에 내장되어 CI/CD를 간편하게 해줄수있는 도구이다. 이를 통해 구축 하려고 하는 방식은 다음과 같다.

Bitbucket 설정

CI/CD를 적용할 레포지토리에 해당 yml을 세팅한다.

kube-deploy.yml

apiVersion: v1
kind: Service
metadata:
name: ansan-daemin-api
spec:
selector:
app: ansan-daemin-api
type: ClusterIP
ports:
- port: 80
targetPort: 7070
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: ansan-daemin-api
spec:
replicas: 1
selector:
matchLabels:
app: ansan-daemin-api
template:
metadata:
labels:
app: ansan-daemin-api
spec:
imagePullSecrets:
- name: registry
containers:
- name: ansan-daemin-api
image: {{image}}
ports:
- containerPort: 7070

kube-deploy.yml은 관리의 편의성과 안정성을 위해서 필요하다. 만약 해당 yml파일이 없더라도

kubectl set image deployment/ansan-daemin-api ansan-daemin-api=이미지명

위의 CLI 명령어를 통해서 배포하는것도 가능하지만, 이렇게 하면 전체 Deployment에 대한 정의가 코드로 남지 않는다. kube-deploy.yml을 만듦으로써, 클러스터 초기화 / 재배포 상황에서 사용이 가능하고, 다른 환경을 구성 하더라도 재사용이 가능하다. 그리고 kubectl apply 를 통해 선언적으로 배포가 가능해진다.

bitbucket.yml

image: atlassian/default-image:4

options:
docker: true
size: 2x

definitions:
services:
docker:
memory: 2048

pipelines:
branches:
main:
- step:
name: Docker build & push
size: 2x
script:
- export IMAGE_NAME=$NCLOUD_CR_URL/$APPLICATION_NAME:$BITBUCKET_COMMIT
- docker build -t $APPLICATION_NAME .
- docker tag $APPLICATION_NAME $IMAGE_NAME
- echo "$NCLOUD_KEY" | docker login -u $NCLOUD_ID $NCLOUD_CR_URL --password-stdin
- docker push $IMAGE_NAME
services:
- docker
caches:
- docker
- step:
name: Deploy to NKS via Bastion (GitOps style)
deployment: production
script:
- export IMAGE_NAME=$NCLOUD_CR_URL/$APPLICATION_NAME:$BITBUCKET_COMMIT
- apt-get update && apt-get install -y sshpass openssh-client
- ssh-keyscan -H $DEV_SERVER_HOST >> ~/.ssh/known_hosts

# YAML에 이미지 치환
- sed -i "s|{{image}}|$IMAGE_NAME|g" kube-deployment.yml

# Bastion에 접속해서 apply
- cat kube-deployment.yml | sshpass -p "$SERVER_PASS" ssh -o StrictHostKeyChecking=no $SERVER_USER@$BASTION_HOST "
export PATH=\$PATH:/home1/ncloud/bin &&
kubectl --kubeconfig /home1/ncloud/kubeconfig-ansan.yaml apply -f -
"

## Develop
develop:
- step:
name: Docker build & push
size: 2x
script:
- export IMAGE_NAME=$NCLOUD_CR_URL/$APPLICATION_NAME:latest
- docker build -t $APPLICATION_NAME .
- docker tag $APPLICATION_NAME $IMAGE_NAME
- echo "$NCLOUD_KEY" | docker login -u $NCLOUD_ID $NCLOUD_CR_URL --password-stdin
- docker push $IMAGE_NAME
services:
- docker
caches:
- docker

- step:
name: Deploy to Server
deployment: test
script:
- export IMAGE_NAME=$NCLOUD_CR_URL/$APPLICATION_NAME:latest
- apt-get update && apt-get install -y sshpass openssh-client
- ssh-keyscan $DEV_SERVER_HOST >> ~/.ssh/known_hosts

- echo "$SERVER_PASS" | sshpass -p "$SERVER_PASS" ssh -o StrictHostKeyChecking=no $SERVER_USER@$BASTION_HOST "
cd $DOCKER_COMPOSE_PATH &&

echo -n \"$NCLOUD_KEY\" | docker login -u \"$NCLOUD_ID\" --password-stdin \"$NCLOUD_CR_URL\" &&

docker-compose stop ansan-daemin-api || true &&
docker-compose rm -f ansan-daemin-api || true &&

docker-compose pull ansan-daemin-api &&
docker-compose up -d ansan-daemin-api &&

docker image prune -f --filter=\"until=24h\"
"

마주쳤던 문제들

InvalidImageName 발생

ncloud@server:~$ kubectl get pods
NAME READY STATUS RESTARTS AGE
ansan-daemin-api-7578988667-ptkqw 0/1 InvalidImageName 0 15h

ALB Health-check 문제

Cluster IP를 기본값으로 설정해서 ALB에서 Health Check가 불가능하였다. Cluster IP는 외부에서 접근할수없어, ALB에서 HealthCheck를 보낼수없기 때문이다. 이를 해결하기 위해 ClusterIP -> NodePort로 변경하여 해결하였다.

Ref.